Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.00% covered (warning)
75.00%
54 / 72
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Transient_Cleanup
75.00% covered (warning)
75.00%
54 / 72
71.43% covered (warning)
71.43%
5 / 7
27.89
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 add_cron_schedule
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 schedule_cleanup
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 unschedule_cleanup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_transient_prefixes
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 run_cleanup
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 purge_expired_transients
45.16% covered (danger)
45.16%
14 / 31
0.00% covered (danger)
0.00%
0 / 1
9.12
1<?php
2/**
3 * Stats Transient Cleanup
4 *
5 * Handles cleanup of expired stats cache transients to prevent database bloat
6 * on sites without a persistent object cache.
7 *
8 * Adapted from Brute Force Protection transient cleanup.
9 *
10 * @package automattic/jetpack-stats
11 */
12
13namespace Automattic\Jetpack\Stats;
14
15/**
16 * Transient_Cleanup class.
17 *
18 * Provides a scheduled cron job to clean up expired stats transients.
19 * WordPress transient garbage collection is "lazy" - expired transients are only
20 * deleted when accessed via get_transient(). Stats transients use dynamic cache keys
21 * based on query parameters, so expired entries are rarely accessed again and
22 * accumulate indefinitely in wp_options on sites without an external object cache.
23 *
24 * @since 0.18.0
25 */
26class Transient_Cleanup {
27
28    /**
29     * Cron hook name.
30     */
31    const CRON_HOOK = 'jetpack_stats_transient_cleanup';
32
33    /**
34     * Batch size for transient cleanup.
35     *
36     * Used as the SQL LIMIT per prefix and as the threshold for stopping iteration.
37     * Actual deletions per run may slightly exceed this when processing multiple prefixes.
38     */
39    const BATCH_SIZE = 5000;
40
41    /**
42     * Cron interval in seconds (8 hours).
43     */
44    const CRON_INTERVAL = 28800;
45
46    /**
47     * Initialize the transient cleanup.
48     *
49     * @return void
50     */
51    public static function init() {
52        // Register the cron hook.
53        add_action( self::CRON_HOOK, array( static::class, 'run_cleanup' ) );
54
55        // Register custom cron schedule.
56        // phpcs:ignore WordPress.WP.CronInterval.ChangeDetected -- 8 hours is intentional for batched transient cleanup.
57        add_filter( 'cron_schedules', array( static::class, 'add_cron_schedule' ) );
58
59        // Schedule the cron job if not already scheduled.
60        add_action( 'admin_init', array( static::class, 'schedule_cleanup' ) );
61    }
62
63    /**
64     * Add custom cron schedule for 8-hour intervals.
65     *
66     * @param array $schedules Existing cron schedules.
67     * @return array Modified cron schedules.
68     */
69    public static function add_cron_schedule( $schedules ) {
70        if ( ! isset( $schedules['jetpack_stats_eight_hours'] ) ) {
71            $schedules['jetpack_stats_eight_hours'] = array(
72                'interval' => self::CRON_INTERVAL,
73                'display'  => __( 'Every Eight Hours', 'jetpack-stats' ),
74            );
75        }
76        return $schedules;
77    }
78
79    /**
80     * Schedule the cleanup cron job.
81     *
82     * Skips scheduling on sites with persistent object cache since transients
83     * auto-expire there and cleanup is unnecessary.
84     *
85     * @return void
86     */
87    public static function schedule_cleanup() {
88        // Skip scheduling on sites with persistent object cache - not needed there.
89        if ( wp_using_ext_object_cache() ) {
90            // Unschedule if it was previously scheduled (e.g., object cache was added later).
91            if ( wp_next_scheduled( self::CRON_HOOK ) ) {
92                wp_clear_scheduled_hook( self::CRON_HOOK );
93            }
94            return;
95        }
96
97        if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
98            wp_schedule_event( time() + self::CRON_INTERVAL, 'jetpack_stats_eight_hours', self::CRON_HOOK );
99        }
100    }
101
102    /**
103     * Unschedule the cleanup cron job.
104     * Hooked to 'jetpack_deactivate_module_stats' in Main::__construct().
105     *
106     * @return void
107     */
108    public static function unschedule_cleanup() {
109        wp_clear_scheduled_hook( self::CRON_HOOK );
110    }
111
112    /**
113     * Get the list of transient prefixes to clean up.
114     *
115     * @return array List of transient prefixes.
116     */
117    public static function get_transient_prefixes() {
118        $prefixes = array(
119            WPCOM_Stats::STATS_CACHE_TRANSIENT_PREFIX, // jetpack_restapi_stats_cache_
120        );
121
122        /**
123         * Filter the list of transient prefixes to clean up.
124         *
125         * @since 0.18.0
126         *
127         * @param array $prefixes List of transient prefixes.
128         */
129        $filtered = apply_filters( 'jetpack_stats_transient_cleanup_prefixes', $prefixes );
130
131        // Normalize filtered value: ensure array, filter to non-empty strings, dedupe.
132        if ( ! is_array( $filtered ) ) {
133            $filtered = $prefixes;
134        }
135
136        $filtered = array_filter(
137            $filtered,
138            function ( $prefix ) {
139                return is_string( $prefix ) && '' !== $prefix;
140            }
141        );
142
143        return array_unique( $filtered );
144    }
145
146    /**
147     * Run the transient cleanup.
148     *
149     * @return int|false Number of deleted transient options (two entries per transient), or false if skipped.
150     */
151    public static function run_cleanup() {
152        /**
153         * Filter to disable transient cleanup.
154         *
155         * @since 0.18.0
156         *
157         * @param bool $disabled Whether to disable transient cleanup. Default false.
158         */
159        if ( apply_filters( 'jetpack_stats_transient_cleanup_disabled', false ) ) {
160            return false;
161        }
162
163        // Skip if using external object cache - transients auto-expire there.
164        if ( wp_using_ext_object_cache() ) {
165            return false;
166        }
167
168        $total_deleted = 0;
169        $prefixes      = self::get_transient_prefixes();
170
171        foreach ( $prefixes as $prefix ) {
172            $deleted        = self::purge_expired_transients( $prefix );
173            $total_deleted += $deleted;
174
175            // Stop processing additional prefixes once we've reached the batch threshold.
176            // Note: total may exceed BATCH_SIZE since each prefix can delete up to BATCH_SIZE.
177            if ( $total_deleted >= self::BATCH_SIZE ) {
178                break;
179            }
180        }
181
182        return $total_deleted;
183    }
184
185    /**
186     * Purge expired transients for a specific prefix.
187     *
188     * @param string $prefix The transient prefix to clean up.
189     * @return int Number of deleted transient options, two entries for each transient.
190     */
191    private static function purge_expired_transients( $prefix ) {
192        global $wpdb;
193
194        $now            = time();
195        $timeout_prefix = '_transient_timeout_' . $prefix;
196        $like_pattern   = $wpdb->esc_like( $timeout_prefix ) . '%';
197
198        // Find expired transients by querying timeout entries.
199        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
200        $transients = $wpdb->get_col(
201            $wpdb->prepare(
202                "SELECT SUBSTRING(option_name, %d) AS transient_name FROM {$wpdb->options} WHERE option_name LIKE %s AND option_value < %d LIMIT %d",
203                strlen( $timeout_prefix ) + 1,
204                $like_pattern,
205                $now,
206                self::BATCH_SIZE
207            )
208        );
209
210        if ( empty( $transients ) ) {
211            return 0;
212        }
213
214        // Build list of option names to delete (both transient and timeout entries).
215        $option_names = array();
216        foreach ( $transients as $transient ) {
217            $option_names[] = '_transient_' . $prefix . $transient;
218            $option_names[] = '_transient_timeout_' . $prefix . $transient;
219        }
220
221        // Delete in chunks to avoid excessively long SQL queries.
222        // Each option name can be ~80 chars, so 50 items ≈ 4KB per query.
223        $chunks        = array_chunk( $option_names, 50 );
224        $total_deleted = 0;
225
226        foreach ( $chunks as $chunk ) {
227            $placeholders = implode( ', ', array_fill( 0, count( $chunk ), '%s' ) );
228
229            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
230            $result = $wpdb->query(
231                $wpdb->prepare(
232                    "DELETE FROM {$wpdb->options} WHERE option_name IN ($placeholders)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders is a list of %s.
233                    $chunk
234                )
235            );
236
237            if ( false !== $result ) {
238                $total_deleted += $result;
239            }
240        }
241
242        return $total_deleted;
243    }
244}