Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.03% covered (warning)
85.03%
125 / 147
44.44% covered (danger)
44.44%
4 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dedicated_Sender
85.03% covered (warning)
85.03%
125 / 147
44.44% covered (danger)
44.44%
4 / 9
50.49
0.00% covered (danger)
0.00%
0 / 1
 prepare_url_for_dedicated_request_check
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_dedicated_sync_request
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
9.38
 spawn_sync
92.11% covered (success)
92.11%
35 / 38
0.00% covered (danger)
0.00%
0 / 1
13.08
 try_lock_spawn_request
84.00% covered (warning)
84.00%
21 / 25
0.00% covered (danger)
0.00%
0 / 1
6.15
 try_release_lock_spawn_request
53.33% covered (warning)
53.33%
8 / 15
0.00% covered (danger)
0.00%
0 / 1
9.66
 get_request_lock_id_from_request
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 can_spawn_dedicated_sync_request
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
4
 on_dedicated_sync_lag_not_sending_threshold_reached
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 maybe_change_dedicated_sync_status_from_wpcom_header
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * Dedicated Sender.
4 *
5 * The class is responsible for spawning dedicated Sync requests.
6 *
7 * @package automattic/jetpack-sync
8 */
9
10namespace Automattic\Jetpack\Sync;
11
12use WP_Error;
13/**
14 * Class to manage Sync spawning.
15 * The purpose of this class is to provide the means to unblock Sync
16 * from running in the shutdown hook of regular requests by spawning a
17 * dedicated Sync request instead which will trigger Sync to run.
18 */
19class Dedicated_Sender {
20
21    /**
22     * The transient name for storing the response code
23     * after spawning a dedicated sync test request.
24     */
25    const DEDICATED_SYNC_CHECK_TRANSIENT = 'jetpack_sync_dedicated_sync_spawn_check';
26
27    /**
28     * Validation string to check if the endpoint is working correctly.
29     *
30     * This is extracted and not hardcoded, as we might want to change it in the future.
31     */
32    const DEDICATED_SYNC_VALIDATION_STRING = 'DEDICATED SYNC OK';
33
34    /**
35     * Option name to use to keep the current request lock.
36     *
37     * The option format is `microtime(true)`.
38     */
39    const DEDICATED_SYNC_REQUEST_LOCK_OPTION_NAME = 'jetpack_sync_dedicated_spawn_lock';
40
41    /**
42     * What's the timeout for the request lock in seconds.
43     *
44     * 5 seconds as default value seems sane, but we might want to adjust that in the future.
45     */
46    const DEDICATED_SYNC_REQUEST_LOCK_TIMEOUT = 60;
47
48    /**
49     * The query parameter name to use when passing the current lock id.
50     */
51    const DEDICATED_SYNC_REQUEST_LOCK_QUERY_PARAM_NAME = 'request_lock_id';
52
53    /**
54     * The name of the transient to use to temporarily disable enabling of Dedicated sync.
55     */
56    const DEDICATED_SYNC_TEMPORARY_DISABLE_FLAG = 'jetpack_sync_dedicated_sync_temp_disable';
57
58    /**
59     * Filter a URL to check if Dedicated Sync is enabled.
60     * We need to remove slashes and then run it through `urldecode` as sometimes the
61     * URL is in an encoded form, depending on server configuration.
62     *
63     * @param string $url The URL to filter.
64     *
65     * @return string
66     */
67    public static function prepare_url_for_dedicated_request_check( $url ) {
68        return urldecode( $url );
69    }
70    /**
71     * Check if this request should trigger Sync to run.
72     *
73     * @access public
74     *
75     * @return boolean True if this is a 'jetpack/v4/sync/spawn-sync', false otherwise.
76     */
77    public static function is_dedicated_sync_request() {
78        /**
79         * Check $_SERVER['REQUEST_URI'] first, to see if we're in the right context.
80         * This is done to make sure we can hook in very early in the initialization of WordPress to
81         * be able to send sync requests to the backend as fast as possible, without needing to continue
82         * loading things for the request.
83         */
84        if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
85            return false;
86        }
87
88        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Recommended
89        $check_url = self::prepare_url_for_dedicated_request_check( wp_unslash( $_SERVER['REQUEST_URI'] ) );
90        if ( strpos( $check_url, 'jetpack/v4/sync/spawn-sync' ) !== false ) {
91            return true;
92        }
93
94        /**
95         * If the above check failed, we might have an issue with detecting calls to the REST endpoint early on.
96         * Sometimes, like when permalinks are disabled, the REST path is sent via the `rest_route` GET parameter.
97         * We want to check it too, to make sure we managed to cover more cases and be more certain we actually
98         * catch calls to the endpoint.
99         */
100        if ( ! isset( $_GET['rest_route'] ) || ! is_string( $_GET['rest_route'] ) ) { //phpcs:ignore WordPress.Security.NonceVerification.Recommended
101            return false;
102        }
103
104        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Recommended
105        $check_url = self::prepare_url_for_dedicated_request_check( wp_unslash( $_GET['rest_route'] ) );
106        if ( strpos( $check_url, 'jetpack/v4/sync/spawn-sync' ) !== false ) {
107            return true;
108        }
109
110        return false;
111    }
112
113    /**
114     * Send a request to run Sync for a certain sync queue
115     * through HTTP request that doesn't halt page loading.
116     *
117     * @access public
118     *
119     * @param \Automattic\Jetpack\Sync\Queue $queue Queue object.
120     *
121     * @return boolean|WP_Error True if spawned, WP_Error otherwise.
122     */
123    public static function spawn_sync( $queue ) {
124        if ( ! Settings::is_dedicated_sync_enabled() ) {
125            return new WP_Error( 'dedicated_sync_disabled', 'Dedicated Sync flow is disabled.' );
126        }
127
128        if ( $queue->is_locked() ) {
129            return new WP_Error( 'locked_queue_' . $queue->id );
130        }
131
132        if ( $queue->size() === 0 ) {
133            return new WP_Error( 'empty_queue_' . $queue->id );
134        }
135
136        if ( get_transient( Sender::TEMP_SYNC_DISABLE_TRANSIENT_NAME ) ) {
137            return new WP_Error( 'sender_temporarily_disabled_while_pulling' );
138        }
139
140        // Return early if we've gotten a retry-after header response that is not expired.
141        $retry_time = get_option( Actions::RETRY_AFTER_PREFIX . $queue->id );
142        if ( $retry_time && $retry_time >= microtime( true ) ) {
143            return new WP_Error( 'retry_after_' . $queue->id );
144        }
145
146        // Don't sync if we are throttled.
147        $sync_next_time = Sender::get_instance()->get_next_sync_time( $queue->id );
148        if ( $sync_next_time > microtime( true ) ) {
149            return new WP_Error( 'sync_throttled_' . $queue->id );
150        }
151        /**
152         * How much time to wait before we start suspecting Dedicated Sync is in trouble.
153         */
154        $queue_send_time_threshold = 30 * MINUTE_IN_SECONDS;
155
156        $queue_lag = $queue->lag();
157
158        /**
159         * Try to acquire a request lock, so we don't spawn multiple requests at the same time.
160         * This should prevent cases where sites might have limits on the amount of simultaneous requests.
161         */
162        $request_lock = self::try_lock_spawn_request();
163        if ( ! $request_lock ) {
164            return new WP_Error( 'dedicated_request_lock', 'Unable to acquire request lock' );
165        }
166
167        /**
168         * If the queue lag is bigger than the threshold, we want to check if Dedicated Sync is working correctly.
169         * We will do by sending a test request and disabling Dedicated Sync if it's not working. We will also exit early
170         * in case we send the test request since it is a blocking request.
171         */
172        if ( $queue_lag > $queue_send_time_threshold ) {
173            if ( false === get_transient( self::DEDICATED_SYNC_CHECK_TRANSIENT ) ) {
174                if ( ! self::can_spawn_dedicated_sync_request() ) {
175                    self::on_dedicated_sync_lag_not_sending_threshold_reached();
176                    return new WP_Error( 'dedicated_sync_not_sending', 'Dedicated Sync is not successfully sending events' );
177                }
178                return true;
179            }
180        }
181
182        $url = rest_url( 'jetpack/v4/sync/spawn-sync' );
183        $url = add_query_arg( 'time', time(), $url ); // Enforce Cache busting.
184        $url = add_query_arg( self::DEDICATED_SYNC_REQUEST_LOCK_QUERY_PARAM_NAME, $request_lock, $url );
185
186        $args = array(
187            'cookies'   => $_COOKIE,
188            'blocking'  => false,
189            'timeout'   => 0.01,
190            /** This filter is documented in wp-includes/class-wp-http-streams.php */
191            'sslverify' => apply_filters( 'https_local_ssl_verify', false ),
192        );
193
194        $result = wp_remote_get( $url, $args );
195        if ( is_wp_error( $result ) ) {
196            return $result;
197        }
198
199        return true;
200    }
201
202    /**
203     * Attempt to acquire a request lock.
204     *
205     * To avoid spawning multiple requests at the same time, we need to have a quick lock that will
206     * allow only a single request to continue if we try to spawn multiple at the same time.
207     *
208     * @return string|false
209     */
210    public static function try_lock_spawn_request() {
211        $option_name  = self::DEDICATED_SYNC_REQUEST_LOCK_OPTION_NAME;
212        $expires_name = $option_name . '_expires';
213        $ttl          = self::DEDICATED_SYNC_REQUEST_LOCK_TIMEOUT;
214        $lock_id      = wp_generate_uuid4();
215        $now          = microtime( true );
216
217        // Fast path: external object cache is atomic.
218        if ( wp_using_ext_object_cache() ) {
219            if ( wp_cache_add( $option_name, $lock_id, 'jetpack', $ttl ) ) {
220                return $lock_id;
221            }
222            return false; // Worker already active
223        }
224
225        global $wpdb;
226
227        // 1) Check & clear expired lock (best effort; failure here is harmless)
228        $expiry = (float) \Jetpack_Options::get_raw_option( $expires_name, 0 );
229        if ( ! $expiry || $expiry < $now ) {
230            // Either missing (edge case) or expired â†’ clean up
231            \Jetpack_Options::delete_raw_option( $option_name );
232            \Jetpack_Options::delete_raw_option( $expires_name );
233        }
234
235        // 2) Atomic acquisition: INSERT IGNORE (succeeds only if the lock doesn't exist)
236        $inserted = $wpdb->query( // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching --- Ensure atomicity.
237            $wpdb->prepare(
238                "INSERT IGNORE INTO $wpdb->options ( option_name, option_value, autoload )
239                 VALUES ( %s, %s, 'no' )",
240                $option_name,
241                maybe_serialize( $lock_id )
242            )
243        );
244
245        if ( $inserted ) {
246            // 3) We own the lock â€” store expiry separately
247            \Jetpack_Options::update_raw_option( $expires_name, $now + $ttl, false );
248            return $lock_id; // Success
249        }
250
251        // Lock already present â†’ normal state â†’ do not spawn
252        return false;
253    }
254
255    /**
256     * Attempt to release the request lock.
257     *
258     * @param string $lock_id The request lock that's currently being held.
259     *
260     * @return bool|WP_Error
261     */
262    public static function try_release_lock_spawn_request( $lock_id = '' ) {
263        // Try to get the lock_id from the current request if it's not supplied.
264        if ( empty( $lock_id ) ) {
265            $lock_id = self::get_request_lock_id_from_request();
266        }
267
268        // If it's still not a valid lock_id, throw an error and let the lock process figure it out.
269        if ( empty( $lock_id ) ) {
270            return new WP_Error( 'dedicated_request_lock_invalid', 'Invalid lock_id supplied for unlock' );
271        }
272
273        if ( wp_using_ext_object_cache() ) {
274            $cached = wp_cache_get( self::DEDICATED_SYNC_REQUEST_LOCK_OPTION_NAME, 'jetpack', true );
275            if ( (string) $lock_id === $cached ) {
276                wp_cache_delete( self::DEDICATED_SYNC_REQUEST_LOCK_OPTION_NAME, 'jetpack' );
277
278                return true;
279            }
280
281            return false;
282        }
283
284        // If this is the flow that has the lock, let's release it so we can spawn other requests afterwards
285        $current_lock_value = \Jetpack_Options::get_raw_option( self::DEDICATED_SYNC_REQUEST_LOCK_OPTION_NAME, null );
286
287        if ( (string) $lock_id === $current_lock_value ) {
288            \Jetpack_Options::delete_raw_option( self::DEDICATED_SYNC_REQUEST_LOCK_OPTION_NAME );
289            return true;
290        }
291
292        return false;
293    }
294
295    /**
296     * Try to get the request lock id from the current request.
297     *
298     * @return array|string|string[]|null
299     */
300    public static function get_request_lock_id_from_request() {
301        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
302        if ( ! isset( $_GET[ self::DEDICATED_SYNC_REQUEST_LOCK_QUERY_PARAM_NAME ] ) ) {
303            return null;
304        }
305
306        // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
307        return wp_unslash( $_GET[ self::DEDICATED_SYNC_REQUEST_LOCK_QUERY_PARAM_NAME ] );
308    }
309
310    /**
311     * Test Sync spawning functionality by making a request to the
312     * Sync spawning endpoint and storing the result (status code) in a transient.
313     *
314     * @since 1.34.0
315     *
316     * @return bool True if we got a successful response, false otherwise.
317     */
318    public static function can_spawn_dedicated_sync_request() {
319        $dedicated_sync_check_transient = self::DEDICATED_SYNC_CHECK_TRANSIENT;
320
321        $dedicated_sync_response_body = get_transient( $dedicated_sync_check_transient );
322
323        if ( false === $dedicated_sync_response_body ) {
324            $url  = rest_url( 'jetpack/v4/sync/spawn-sync' );
325            $url  = add_query_arg( 'time', time(), $url ); // Enforce Cache busting.
326            $args = array(
327                'cookies'   => $_COOKIE,
328                'timeout'   => 30,
329                /** This filter is documented in wp-includes/class-wp-http-streams.php */
330                'sslverify' => apply_filters( 'https_local_ssl_verify', false ),
331            );
332
333            $response                     = wp_remote_get( $url, $args );
334            $dedicated_sync_response_code = wp_remote_retrieve_response_code( $response );
335            $dedicated_sync_response_body = trim( wp_remote_retrieve_body( $response ) );
336
337            /**
338             * Limit the size of the body that we save in the transient to avoid cases where an error
339             * occurs and a whole generated HTML page is returned. We don't need to store the whole thing.
340             *
341             * The regexp check is done to make sure we can detect the string even if the body returns some additional
342             * output, like some caching plugins do when they try to pad the request.
343             */
344            $regexp = '!' . preg_quote( self::DEDICATED_SYNC_VALIDATION_STRING, '!' ) . '!uis';
345            if ( preg_match( $regexp, $dedicated_sync_response_body ) ) {
346                $saved_response_body = self::DEDICATED_SYNC_VALIDATION_STRING;
347            } else {
348                $saved_response_body = time();
349            }
350
351            set_transient( $dedicated_sync_check_transient, $saved_response_body, HOUR_IN_SECONDS );
352
353            // Send a bit more information to WordPress.com to help debugging issues.
354            if ( $saved_response_body !== self::DEDICATED_SYNC_VALIDATION_STRING ) {
355                $data = array(
356                    'timestamp'      => microtime( true ),
357                    'response_code'  => $dedicated_sync_response_code,
358                    'response_body'  => $dedicated_sync_response_body,
359
360                    // Send the flow type that was attempted.
361                    'sync_flow_type' => 'dedicated',
362                );
363
364                $sender = Sender::get_instance();
365
366                $sender->send_action( 'jetpack_sync_flow_error_enable', $data );
367            }
368        }
369        return self::DEDICATED_SYNC_VALIDATION_STRING === $dedicated_sync_response_body;
370    }
371
372    /**
373     * Disable dedicated sync and set a transient to prevent re-enabling it for some time.
374     *
375     * @return void
376     */
377    public static function on_dedicated_sync_lag_not_sending_threshold_reached() {
378        set_transient( self::DEDICATED_SYNC_TEMPORARY_DISABLE_FLAG, true, 6 * HOUR_IN_SECONDS );
379
380        Settings::update_settings(
381            array(
382                'dedicated_sync_enabled' => 0,
383            )
384        );
385
386        // Inform that we had to temporarily disable Dedicated Sync
387        $data = array(
388            'timestamp'      => microtime( true ),
389
390            // Send the flow type that was attempted.
391            'sync_flow_type' => 'dedicated',
392        );
393
394        $sender = Sender::get_instance();
395
396        $sender->send_action( 'jetpack_sync_flow_error_temp_disable', $data );
397    }
398
399    /**
400     * Disable or enable Dedicated Sync sender based on the header value returned from WordPress.com
401     *
402     * @param string $dedicated_sync_header The Dedicated Sync header value - `on` or `off`.
403     *
404     * @return bool Whether Dedicated Sync is going to be enabled or not.
405     */
406    public static function maybe_change_dedicated_sync_status_from_wpcom_header( $dedicated_sync_header ) {
407        $dedicated_sync_enabled = 'on' === $dedicated_sync_header ? 1 : 0;
408
409        // Prevent enabling of Dedicated sync via header flag if we're in an autoheal timeout.
410        if ( $dedicated_sync_enabled ) {
411            $check_transient = get_transient( self::DEDICATED_SYNC_TEMPORARY_DISABLE_FLAG );
412
413            if ( $check_transient ) {
414                // Something happened and Dedicated Sync should not be automatically re-enabled.
415                return false;
416            }
417        }
418
419        $current_setting = Settings::is_dedicated_sync_enabled();
420
421        // No need to update if current setting matches header value.
422        if ( $current_setting === (bool) $dedicated_sync_enabled ) {
423            return $current_setting;
424        }
425
426        Settings::update_settings(
427            array(
428                'dedicated_sync_enabled' => $dedicated_sync_enabled,
429            )
430        );
431
432        return Settings::is_dedicated_sync_enabled();
433    }
434}