PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 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 All 507 releases
← All changes | jetpack_vendor/automattic/jetpack-sync/src/class-dedicated-sender.php +434 -0 16.2-beta → 16.3-beta View file →
@@ -1,0 +1,434 @@
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 +}