PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
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 13.9.2 14.0.1 All 504 releases
← All changes | jetpack_vendor/automattic/jetpack-sync/src/modules/class-module.php +302 -38 12.2.3 → 16.3-a.1 View file →
@@ -6,8 +6,9 @@
6 6 */
7 7
8 8 namespace Automattic\Jetpack\Sync\Modules;
9 9
10 +use Automattic\Jetpack\Sync\Defaults;
10 11 use Automattic\Jetpack\Sync\Functions;
11 12 use Automattic\Jetpack\Sync\Listener;
12 13 use Automattic\Jetpack\Sync\Replicastore;
13 14 use Automattic\Jetpack\Sync\Sender;
@@ -12,8 +13,12 @@
12 13 use Automattic\Jetpack\Sync\Replicastore;
13 14 use Automattic\Jetpack\Sync\Sender;
14 15 use Automattic\Jetpack\Sync\Settings;
15 16
17 +if ( ! defined( 'ABSPATH' ) ) {
18 + exit( 0 );
19 +}
20 +
16 21 /**
17 22 * Basic methods implemented by Jetpack Sync extensions.
18 23 *
19 24 * @abstract
@@ -28,8 +33,37 @@
28 33 */
29 34 const ARRAY_CHUNK_SIZE = 10;
30 35
31 36 /**
37 + * Max query length for DB queries.
38 + *
39 + * @access public
40 + *
41 + * @var int
42 + */
43 + const MAX_DB_QUERY_LENGTH = 15 * 1024;
44 +
45 + /**
46 + * Max bytes allowed for full sync upload for the module.
47 + * Default Setting : 7MB.
48 + *
49 + * @access public
50 + *
51 + * @var int
52 + */
53 + const MAX_SIZE_FULL_SYNC = 7000000;
54 +
55 + /**
56 + * Max bytes allowed for post meta_value => length.
57 + * Default Setting : 2MB.
58 + *
59 + * @access public
60 + *
61 + * @var int
62 + */
63 + const MAX_META_LENGTH = 2000000;
64 +
65 + /**
32 66 * Sync module name.
33 67 *
34 68 * @access public
35 69 *
@@ -48,18 +82,42 @@
48 82 return 'ID';
49 83 }
50 84
51 85 /**
52 - * The table in the database.
86 + * The table name.
53 87 *
54 88 * @access public
55 89 *
56 90 * @return string|bool
91 + * @deprecated since 3.11.0 Use table() instead.
57 92 */
58 93 public function table_name() {
94 + _deprecated_function( __METHOD__, '3.11.0', 'Automattic\\Jetpack\\Sync\\Module->table' );
59 95 return false;
60 96 }
61 97
98 + /**
99 + * The table in the database with the prefix.
100 + *
101 + * @access public
102 + *
103 + * @return string|bool
104 + */
105 + public function table() {
106 + return false;
107 + }
108 +
109 + /**
110 + * The full sync action name for this module.
111 + *
112 + * @access public
113 + *
114 + * @return string
115 + */
116 + public function full_sync_action_name() {
117 + return 'jetpack_full_sync_' . $this->name();
118 + }
119 +
62 120 // phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
63 121
64 122 /**
65 123 * Retrieve a sync object by its ID.
@@ -140,9 +198,9 @@
140 198 *
141 199 * @access public
142 200 *
143 201 * @param array $config Full sync configuration for this sync module.
144 - * @return array Number of items yet to be enqueued.
202 + * @return int Number of items yet to be enqueued.
145 203 */
146 204 public function estimate_full_sync_actions( $config ) {
147 205 // In subclasses, return the number of items yet to be enqueued.
148 206 return null;
@@ -187,9 +245,14 @@
187 245 // Associative array order changes the generated checksum value.
188 246 if ( $sort && is_array( $values ) ) {
189 247 $this->recursive_ksort( $values );
190 248 }
191 - return crc32( wp_json_encode( Functions::json_wrap( $values ) ) );
249 + return crc32(
250 + wp_json_encode(
251 + Functions::json_wrap( $values ),
252 + 0 // phpcs:ignore Jetpack.Functions.JsonEncodeFlags.ZeroFound -- No `json_encode()` flags because we don't want disrupt the checksum algorithm.
253 + )
254 + );
192 255 }
193 256
194 257 /**
195 258 * Recursively call ksort on an Array
@@ -249,9 +312,9 @@
249 312 $previous_interval_end = $state ? $state : '~0';
250 313 $listener = Listener::get_instance();
251 314
252 315 // Count down from max_id to min_id so we get newest posts/comments/etc first.
253 - // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
316 + // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
254 317 while ( $ids = $wpdb->get_col( "SELECT {$id_field} FROM {$table_name} WHERE {$where_sql} AND {$id_field} < {$previous_interval_end} ORDER BY {$id_field} DESC LIMIT {$items_per_page}" ) ) {
255 318 // Request posts in groups of N for efficiency.
256 319 $chunked_ids = array_chunk( $ids, self::ARRAY_CHUNK_SIZE );
257 320
@@ -292,24 +355,46 @@
292 355 *
293 356 * @return array|object|null
294 357 */
295 358 public function get_next_chunk( $config, $status, $chunk_size ) {
296 - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
359 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery
297 360 global $wpdb;
298 361 return $wpdb->get_col(
299 - <<<SQL
300 -SELECT {$this->id_field()}
301 -FROM {$wpdb->{$this->table_name()}}
302 -WHERE {$this->get_where_sql( $config )}
303 -AND {$this->id_field()} < {$status['last_sent']}
304 -ORDER BY {$this->id_field()}
305 -DESC LIMIT {$chunk_size}
306 -SQL
362 + "
363 + SELECT {$this->id_field()}
364 + FROM {$this->table()}
365 + WHERE {$this->get_where_sql( $config )}
366 + AND {$this->id_field()} < {$status['last_sent']}
367 + ORDER BY {$this->id_field()}
368 + DESC LIMIT {$chunk_size}
369 + "
307 370 );
308 - // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
371 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery
309 372 }
310 373
311 374 /**
375 + * Return last_item to send for Module Full Sync Configuration.
376 + *
377 + * @param array $config This module Full Sync configuration.
378 + *
379 + * @return array|object|null
380 + */
381 + public function get_last_item( $config ) {
382 + global $wpdb;
383 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.DirectQuery
384 + return $wpdb->get_var(
385 + "
386 + SELECT {$this->id_field()}
387 + FROM {$this->table()}
388 + WHERE {$this->get_where_sql( $config )}
389 + ORDER BY {$this->id_field()}
390 + LIMIT 1
391 + "
392 + );
393 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.DirectQuery
394 + }
395 +
396 + /**
312 397 * Return the initial last sent object.
313 398 *
314 399 * @return string|array initial status.
315 400 */
@@ -321,15 +406,16 @@
321 406 * Immediately send all items of a sync type as an action.
322 407 *
323 408 * @access protected
324 409 *
325 - * @param string $config Full sync configuration for this module.
326 - * @param array $status the current module full sync status.
327 - * @param float $send_until timestamp until we want this request to send full sync events.
410 + * @param array $config Full sync configuration for this module.
411 + * @param array $status the current module full sync status.
412 + * @param float $send_until timestamp until we want this request to send full sync events.
413 + * @param int $started The timestamp when the full sync started.
328 414 *
329 415 * @return array Status, the module full sync status updated.
330 416 */
331 - public function send_full_sync_actions( $config, $status, $send_until ) {
417 + public function send_full_sync_actions( $config, $status, $send_until, $started ) {
332 418 global $wpdb;
333 419
334 420 if ( empty( $status['last_sent'] ) ) {
335 421 $status['last_sent'] = $this->get_initial_last_sent();
@@ -334,32 +420,150 @@
334 420 if ( empty( $status['last_sent'] ) ) {
335 421 $status['last_sent'] = $this->get_initial_last_sent();
336 422 }
337 423
338 - $limits = Settings::get_setting( 'full_sync_limits' )[ $this->name() ];
424 + $limits = Settings::get_setting( 'full_sync_limits' )[ $this->name() ] ??
425 + Defaults::get_default_setting( 'full_sync_limits' )[ $this->name() ] ??
426 + array(
427 + 'max_chunks' => null,
428 + 'chunk_size' => null,
429 + );
339 430
431 + $limits = array(
432 + 'max_chunks' => is_numeric( $limits['max_chunks'] ) ? (int) $limits['max_chunks'] : 10,
433 + 'chunk_size' => is_numeric( $limits['chunk_size'] ) ? (int) $limits['chunk_size'] : 100,
434 + );
435 +
436 + $limits['chunk_size'] = $this->adjust_chunk_size_if_stuck( $status['last_sent'], $limits['chunk_size'], $started );
437 +
340 438 $chunks_sent = 0;
341 - // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
342 - while ( $objects = $this->get_next_chunk( $config, $status, $limits['chunk_size'] ) ) {
343 - if ( $chunks_sent++ === $limits['max_chunks'] || microtime( true ) >= $send_until ) {
439 +
440 + // Store last_item in status to avoid re-running this expensive query on every invocation.
441 + // The minimum ID does not change during a Full Sync.
442 + if ( ! isset( $status['last_item'] ) ) {
443 + $status['last_item'] = $this->get_last_item( $config );
444 + }
445 + $last_item = $status['last_item'];
446 +
447 + while ( $chunks_sent < $limits['max_chunks'] && microtime( true ) < $send_until ) {
448 + $objects = $this->get_next_chunk( $config, $status, $limits['chunk_size'] );
449 +
450 + if ( $wpdb->last_error ) {
451 + $status['error'] = true;
344 452 return $status;
345 453 }
346 454
347 - $result = $this->send_action( 'jetpack_full_sync_' . $this->name(), array( $objects, $status['last_sent'] ) );
455 + if ( empty( $objects ) ) {
456 + $status['finished'] = true;
457 + return $status;
458 + }
459 + // If we have objects as a key it means get_next_chunk is being overridden, we need to check for it being an empty array.
460 + // In case it is an empty array, we should not send the action or increase the chunks_sent, we just need to update the status.
461 + if ( ! isset( $objects['objects'] ) || array() !== $objects['objects'] ) {
462 + $key = $this->full_sync_action_name() . '_' . crc32( wp_json_encode( $status['last_sent'], JSON_UNESCAPED_SLASHES ) );
463 + $result = $this->send_action( $this->full_sync_action_name(), array( $objects, $status['last_sent'] ), $key );
464 + if ( is_wp_error( $result ) || $wpdb->last_error ) {
465 + $status['error'] = true;
466 + return $status;
467 + }
468 + ++$chunks_sent;
469 + }
348 470
349 - if ( is_wp_error( $result ) || $wpdb->last_error ) {
350 - $status['error'] = true;
471 + // Updated the sent and last_sent status.
472 + $status = $this->set_send_full_sync_actions_status( $status, $objects );
473 + if ( $last_item === $status['last_sent'] ) {
474 + $status['finished'] = true;
351 475 return $status;
352 476 }
353 - // The $ids are ordered in descending order.
354 - $status['last_sent'] = end( $objects );
355 - $status['sent'] += count( $objects );
356 477 }
357 478
358 - if ( ! $wpdb->last_error ) {
359 - $status['finished'] = true;
479 + return $status;
480 + }
481 +
482 + /**
483 + * Adjust chunk size using adaptive logic and update transient for tracking stuck state.
484 + *
485 + * @param string $last_sent The current last_sent marker.
486 + * @param int $default_chunk_size The default chunk size.
487 + * @param int $started The timestamp when the full sync started.
488 + * @return int Adjusted chunk size.
489 + */
490 + private function adjust_chunk_size_if_stuck( $last_sent, $default_chunk_size, $started ) {
491 + $transient_key = 'jetpack_sync_last_sent_' . $this->name() . '_' . $started;
492 + $stuck_data = get_transient( $transient_key );
493 + $is_stuck = isset( $stuck_data['last_sent'] ) && $stuck_data['last_sent'] === $last_sent;
494 +
495 + // Preserve the adjusted chunk size and stuck count from the transient when stuck.
496 + $stuck_count = $is_stuck && isset( $stuck_data['stuck_count'] ) ? $stuck_data['stuck_count'] : 0;
497 + $adjusted_chunk_size = $is_stuck && isset( $stuck_data['adjusted_chunk_size'] ) ? $stuck_data['adjusted_chunk_size'] : $default_chunk_size;
498 +
499 + if ( $is_stuck && $stuck_data['adjusted_chunk_size'] === 1 ) {
500 + // Refresh transient TTL to prevent expiry-driven reset cycles.
501 + set_transient( $transient_key, $stuck_data, HOUR_IN_SECONDS );
502 + return 1; // If we are already at the minimum chunk size, do not adjust further.
360 503 }
361 504
505 + // We will adjust if it is stuck after 10 minutes.
506 + if (
507 + $is_stuck &&
508 + ( time() - $stuck_data['timestamp'] ) >= 10 * MINUTE_IN_SECONDS
509 + ) {
510 + ++$stuck_count;
511 + $adjusted_chunk_size = max( 1, (int) ( $default_chunk_size / ( 2 ** $stuck_count ) ) ); // Halve the chunk size for each stuck iteration.
512 +
513 + // Send one HTTP notification when chunk size reaches the minimum (1)
514 + // so the stuck state is visible for monitoring. Intermediate cascade
515 + // steps skip the HTTP request to avoid consuming the time budget.
516 + if ( 1 === $adjusted_chunk_size ) {
517 + $this->send_action(
518 + 'jetpack_full_sync_stuck_adjustment',
519 + array(
520 + 'module' => $this->name(),
521 + 'last_sent' => $last_sent,
522 + 'stuck_count' => $stuck_count,
523 + 'adjusted_chunk_size' => $adjusted_chunk_size,
524 + )
525 + );
526 + }
527 + }
528 +
529 + // Set or update the transient with the new last_sent, timestamp, and stuck_count.
530 + // Reset the timestamp when not stuck or after an adjustment, so each new chunk size
531 + // gets a 10-minute window to prove itself before halving further.
532 + $previous_chunk_size = $stuck_data['adjusted_chunk_size'] ?? null;
533 + $reset_timestamp = ! $is_stuck || $adjusted_chunk_size !== $previous_chunk_size;
534 + set_transient(
535 + $transient_key,
536 + array(
537 + 'last_sent' => $last_sent,
538 + 'timestamp' => $reset_timestamp ? time() : $stuck_data['timestamp'],
539 + 'stuck_count' => $stuck_count,
540 + 'adjusted_chunk_size' => $adjusted_chunk_size,
541 + ),
542 + HOUR_IN_SECONDS
543 + );
544 +
545 + return $adjusted_chunk_size;
546 + }
547 +
548 + /**
549 + * Set the status of the full sync action based on the objects that were sent.
550 + * Used to update the status of the module after sending a chunk of objects.
551 + * Since Full Sync logic chunking relies on order of items being processed in descending order, we need to sort
552 + * due to some modules (e.g. WooCommerce) changing the order while getting the objects.
553 + *
554 + * @access protected
555 + *
556 + * @param array $status This module Full Sync status.
557 + * @param array $objects This module Full Sync objects.
558 + *
559 + * @return array The updated status.
560 + */
561 + protected function set_send_full_sync_actions_status( $status, $objects ) {
562 +
563 + $object_ids = $objects['object_ids'] ?? $objects;
564 + $status['last_sent'] = end( $object_ids );
565 + $status['sent'] += count( $object_ids );
362 566 return $status;
363 567 }
364 568
365 569 /**
@@ -366,12 +570,13 @@
366 570 * Immediately sends a single item without firing or enqueuing it
367 571 *
368 572 * @param string $action_name The action.
369 573 * @param array $data The data associated with the action.
574 + * @param string $key The key to use for the action.
370 575 */
371 - public function send_action( $action_name, $data = null ) {
576 + public function send_action( $action_name, $data = null, $key = null ) {
372 577 $sender = Sender::get_instance();
373 - return $sender->send_action( $action_name, $data );
578 + return $sender->send_action( $action_name, $data, $key );
374 579 }
375 580
376 581 /**
377 582 * Retrieve chunk IDs with previous interval end.
@@ -524,16 +729,15 @@
524 729 *
525 730 * @return array|bool An array of min and max ids for each batch. FALSE if no table can be found.
526 731 */
527 732 public function get_min_max_object_ids_for_batches( $batch_size, $where_sql = false ) {
528 - global $wpdb;
529 733
530 - if ( ! $this->table_name() ) {
734 + if ( ! $this->table() ) {
531 735 return false;
532 736 }
533 737
534 738 $results = array();
535 - $table = $wpdb->{$this->table_name()};
739 + $table = $this->table();
536 740 $current_max = 0;
537 741 $current_min = 1;
538 742 $id_field = $this->id_field();
539 743 $replicastore = new Replicastore();
@@ -581,13 +785,13 @@
581 785 * @return int total
582 786 */
583 787 public function total( $config ) {
584 788 global $wpdb;
585 - $table = $wpdb->{$this->table_name()};
789 + $table = $this->table();
586 790 $where = $this->get_where_sql( $config );
587 791
588 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
589 - return $wpdb->get_var( "SELECT COUNT(*) FROM $table WHERE $where" );
792 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
793 + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM $table WHERE $where" );
590 794 }
591 795
592 796 /**
593 797 * Retrieve the WHERE SQL clause based on the module config.
@@ -600,5 +804,65 @@
600 804 public function get_where_sql( $config ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
601 805 return '1=1';
602 806 }
603 807
808 + /**
809 + * Filters objects and metadata based on maximum size constraints.
810 + * It always allows the first object with its metadata, even if they exceed the limit.
811 + *
812 + * @access public
813 + *
814 + * @param string $type The type of objects to filter (e.g., 'post' or 'comment').
815 + * @param array $objects The array of objects to filter (e.g., posts or comments).
816 + * @param array $metadata The array of metadata to filter.
817 + * @param int $max_meta_size Maximum size for individual objects.
818 + * @param int $max_total_size Maximum combined size for objects and metadata.
819 + * @return array An array containing the filtered object IDs, filtered objects, and filtered metadata.
820 + */
821 + public function filter_objects_and_metadata_by_size( $type, $objects, $metadata, $max_meta_size, $max_total_size ) {
822 + $filtered_objects = array();
823 + $filtered_metadata = array();
824 + $filtered_object_ids = array();
825 + $current_size = 0;
826 +
827 + foreach ( $objects as $object ) {
828 + $object_size = strlen( (string) maybe_serialize( $object ) );
829 + $current_metadata = array();
830 + $metadata_size = 0;
831 + $id_field = $this->id_field();
832 + $object_id = (int) ( is_object( $object ) ? $object->{$id_field} : $object[ $id_field ] );
833 +
834 + foreach ( $metadata as $key => $metadata_item ) {
835 + if ( (int) $metadata_item->{$type . '_id'} === $object_id ) {
836 + $metadata_item_size = strlen( (string) maybe_serialize( $metadata_item ) );
837 + if ( $metadata_item_size >= $max_meta_size ) {
838 + $metadata_item->meta_value = ''; // Trim metadata if too large.
839 + $metadata_item_size = strlen( (string) maybe_serialize( $metadata_item ) );
840 + }
841 + $current_metadata[] = $metadata_item;
842 + $metadata_size += $metadata_item_size;
843 +
844 + if ( ! empty( $filtered_object_ids ) && ( $current_size + $object_size + $metadata_size ) > $max_total_size ) {
845 + break 2; // Exit both loops.
846 + }
847 + unset( $metadata[ $key ] );
848 + }
849 + }
850 +
851 + // Always allow the first object with metadata.
852 + if ( empty( $filtered_object_ids ) || ( $current_size + $object_size + $metadata_size ) <= $max_total_size ) {
853 + $filtered_object_ids[] = strval( is_object( $object ) ? $object->{$id_field} : $object[ $id_field ] );
854 + $filtered_objects[] = $object;
855 + $filtered_metadata = array_merge( $filtered_metadata, $current_metadata );
856 + $current_size += $object_size + $metadata_size;
857 + } else {
858 + break;
859 + }
860 + }
861 +
862 + return array(
863 + $filtered_object_ids,
864 + $filtered_objects,
865 + $filtered_metadata,
866 + );
867 + }
604 868 }