PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.5
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.5
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-sync / src / class-dedicated-sender.php

class-dedicated-sender.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.5, at jetpack_vendor/automattic/jetpack-sync/src/class-dedicated-sender.php

435 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 * Dedicated Sender.
4 *
5 * The class is responsible for spawning dedicated Sync requests.
6 *
7 * @package automattic/jetpack-sync
8 */
9
10 namespace Automattic\Jetpack\Sync;
11
12 use 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 */
19 class 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 -- Only substring-matched against a fixed literal; never output or stored.
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 -- Only substring-matched against a fixed literal; never output or stored.
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 }
435