Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
15.89% covered (danger)
15.89%
41 / 258
14.29% covered (danger)
14.29%
5 / 35
CRAP
0.00% covered (danger)
0.00%
0 / 1
Module
15.62% covered (danger)
15.62%
40 / 256
14.29% covered (danger)
14.29%
5 / 35
5288.26
0.00% covered (danger)
0.00%
0 / 1
 name
n/a
0 / 0
n/a
0 / 0
0
 id_field
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 table_name
n/a
0 / 0
n/a
0 / 0
1
 table
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 full_sync_action_name
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_object_by_id
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 init_listeners
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init_full_sync_listeners
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init_before_send
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_defaults
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reset_data
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 enqueue_full_sync_actions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 estimate_full_sync_actions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_full_sync_actions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 count_actions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_check_sum
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 recursive_ksort
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 still_valid_checksum
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 enqueue_all_ids_as_action
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
42
 get_next_chunk
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 get_last_item
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 get_initial_last_sent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 send_full_sync_actions
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
210
 adjust_chunk_size_if_stuck
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
182
 set_send_full_sync_actions_status
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 send_action
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 get_chunks_with_preceding_end
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 get_metadata
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 init_listeners_for_meta_type
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 init_meta_whitelist_handler
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get_term_relationships
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unserialize_meta
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 get_objects_by_id
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 get_min_max_object_ids_for_batches
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
42
 total
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 get_where_sql
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 filter_objects_and_metadata_by_size
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
11
1<?php
2/**
3 * A base abstraction of a sync module.
4 *
5 * @package automattic/jetpack-sync
6 */
7
8namespace Automattic\Jetpack\Sync\Modules;
9
10use Automattic\Jetpack\Sync\Defaults;
11use Automattic\Jetpack\Sync\Functions;
12use Automattic\Jetpack\Sync\Listener;
13use Automattic\Jetpack\Sync\Replicastore;
14use Automattic\Jetpack\Sync\Sender;
15use Automattic\Jetpack\Sync\Settings;
16
17if ( ! defined( 'ABSPATH' ) ) {
18    exit( 0 );
19}
20
21/**
22 * Basic methods implemented by Jetpack Sync extensions.
23 *
24 * @abstract
25 */
26abstract class Module {
27    /**
28     * Number of items per chunk when grouping objects for performance reasons.
29     *
30     * @access public
31     *
32     * @var int
33     */
34    const ARRAY_CHUNK_SIZE = 10;
35
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    /**
66     * Sync module name.
67     *
68     * @access public
69     *
70     * @return string
71     */
72    abstract public function name();
73
74    /**
75     * The id field in the database.
76     *
77     * @access public
78     *
79     * @return string
80     */
81    public function id_field() {
82        return 'ID';
83    }
84
85    /**
86     * The table name.
87     *
88     * @access public
89     *
90     * @return string|bool
91     * @deprecated since 3.11.0 Use table() instead.
92     */
93    public function table_name() {
94        _deprecated_function( __METHOD__, '3.11.0', 'Automattic\\Jetpack\\Sync\\Module->table' );
95        return false;
96    }
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
120    // phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
121
122    /**
123     * Retrieve a sync object by its ID.
124     *
125     * @access public
126     *
127     * @param string $object_type Type of the sync object.
128     * @param int    $id          ID of the sync object.
129     * @return mixed Object, or false if the object is invalid.
130     */
131    public function get_object_by_id( $object_type, $id ) {
132        return false;
133    }
134
135    /**
136     * Initialize callables action listeners.
137     * Override these to set up listeners and set/reset data/defaults.
138     *
139     * @access public
140     *
141     * @param callable $callable Action handler callable.
142     */
143    public function init_listeners( $callable ) {
144    }
145
146    /**
147     * Initialize module action listeners for full sync.
148     *
149     * @access public
150     *
151     * @param callable $callable Action handler callable.
152     */
153    public function init_full_sync_listeners( $callable ) {
154    }
155
156    /**
157     * Initialize the module in the sender.
158     *
159     * @access public
160     */
161    public function init_before_send() {
162    }
163
164    /**
165     * Set module defaults.
166     *
167     * @access public
168     */
169    public function set_defaults() {
170    }
171
172    /**
173     * Perform module cleanup.
174     * Usually triggered when uninstalling the plugin.
175     *
176     * @access public
177     */
178    public function reset_data() {
179    }
180
181    /**
182     * Enqueue the module actions for full sync.
183     *
184     * @access public
185     *
186     * @param array   $config               Full sync configuration for this sync module.
187     * @param int     $max_items_to_enqueue Maximum number of items to enqueue.
188     * @param boolean $state                True if full sync has finished enqueueing this module, false otherwise.
189     * @return array  Number of actions enqueued, and next module state.
190     */
191    public function enqueue_full_sync_actions( $config, $max_items_to_enqueue, $state ) {
192        // In subclasses, return the number of actions enqueued, and next module state (true == done).
193        return array( null, true );
194    }
195
196    /**
197     * Retrieve an estimated number of actions that will be enqueued.
198     *
199     * @access public
200     *
201     * @param array $config Full sync configuration for this sync module.
202     * @return int Number of items yet to be enqueued.
203     */
204    public function estimate_full_sync_actions( $config ) {
205        // In subclasses, return the number of items yet to be enqueued.
206        return null;
207    }
208
209    // phpcs:enable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
210
211    /**
212     * Retrieve the actions that will be sent for this module during a full sync.
213     *
214     * @access public
215     *
216     * @return array Full sync actions of this module.
217     */
218    public function get_full_sync_actions() {
219        return array();
220    }
221
222    /**
223     * Get the number of actions that we care about.
224     *
225     * @access protected
226     *
227     * @param array $action_names     Action names we're interested in.
228     * @param array $actions_to_count Unfiltered list of actions we want to count.
229     * @return array Number of actions that we're interested in.
230     */
231    protected function count_actions( $action_names, $actions_to_count ) {
232        return count( array_intersect( $action_names, $actions_to_count ) );
233    }
234
235    /**
236     * Calculate the checksum of one or more values.
237     *
238     * @access protected
239     *
240     * @param mixed $values Values to calculate checksum for.
241     * @param bool  $sort If $values should have ksort called on it.
242     * @return int The checksum.
243     */
244    protected function get_check_sum( $values, $sort = true ) {
245        // Associative array order changes the generated checksum value.
246        if ( $sort && is_array( $values ) ) {
247            $this->recursive_ksort( $values );
248        }
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        );
255    }
256
257    /**
258     * Recursively call ksort on an Array
259     *
260     * @param array $values Array.
261     */
262    private function recursive_ksort( &$values ) {
263        ksort( $values );
264        foreach ( $values as &$value ) {
265            if ( is_array( $value ) ) {
266                $this->recursive_ksort( $value );
267            }
268        }
269    }
270
271    /**
272     * Whether a particular checksum in a set of checksums is valid.
273     *
274     * @access protected
275     *
276     * @param array  $sums_to_check Array of checksums.
277     * @param string $name          Name of the checksum.
278     * @param int    $new_sum       Checksum to compare against.
279     * @return boolean Whether the checksum is valid.
280     */
281    protected function still_valid_checksum( $sums_to_check, $name, $new_sum ) {
282        if ( isset( $sums_to_check[ $name ] ) && $sums_to_check[ $name ] === $new_sum ) {
283            return true;
284        }
285
286        return false;
287    }
288
289    /**
290     * Enqueue all items of a sync type as an action.
291     *
292     * @access protected
293     *
294     * @param string  $action_name          Name of the action.
295     * @param string  $table_name           Name of the database table.
296     * @param string  $id_field             Name of the ID field in the database.
297     * @param string  $where_sql            The SQL WHERE clause to filter to the desired items.
298     * @param int     $max_items_to_enqueue Maximum number of items to enqueue in the same time.
299     * @param boolean $state                Whether enqueueing has finished.
300     * @return array Array, containing the number of chunks and TRUE, indicating enqueueing has finished.
301     */
302    protected function enqueue_all_ids_as_action( $action_name, $table_name, $id_field, $where_sql, $max_items_to_enqueue, $state ) {
303        global $wpdb;
304
305        if ( ! $where_sql ) {
306            $where_sql = '1 = 1';
307        }
308
309        $items_per_page        = 1000;
310        $page                  = 1;
311        $chunk_count           = 0;
312        $previous_interval_end = $state ? $state : '~0';
313        $listener              = Listener::get_instance();
314
315        // Count down from max_id to min_id so we get newest posts/comments/etc first.
316        // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
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}" ) ) {
318            // Request posts in groups of N for efficiency.
319            $chunked_ids = array_chunk( $ids, self::ARRAY_CHUNK_SIZE );
320
321            // If we hit our row limit, process and return.
322            if ( $chunk_count + count( $chunked_ids ) >= $max_items_to_enqueue ) {
323                $remaining_items_count                      = $max_items_to_enqueue - $chunk_count;
324                $remaining_items                            = array_slice( $chunked_ids, 0, $remaining_items_count );
325                $remaining_items_with_previous_interval_end = $this->get_chunks_with_preceding_end( $remaining_items, $previous_interval_end );
326                $listener->bulk_enqueue_full_sync_actions( $action_name, $remaining_items_with_previous_interval_end );
327
328                $last_chunk = end( $remaining_items );
329                return array( $remaining_items_count + $chunk_count, end( $last_chunk ) );
330            }
331            $chunked_ids_with_previous_end = $this->get_chunks_with_preceding_end( $chunked_ids, $previous_interval_end );
332
333            $listener->bulk_enqueue_full_sync_actions( $action_name, $chunked_ids_with_previous_end );
334
335            $chunk_count += count( $chunked_ids );
336            ++$page;
337            // The $ids are ordered in descending order.
338            $previous_interval_end = end( $ids );
339        }
340
341        if ( $wpdb->last_error ) {
342            // return the values that were passed in so all these chunks get retried.
343            return array( $max_items_to_enqueue, $state );
344        }
345
346        return array( $chunk_count, true );
347    }
348
349    /**
350     * Given the Module Full Sync Configuration and Status return the next chunk of items to send.
351     *
352     * @param array $config This module Full Sync configuration.
353     * @param array $status This module Full Sync status.
354     * @param int   $chunk_size Chunk size.
355     *
356     * @return array|object|null
357     */
358    public function get_next_chunk( $config, $status, $chunk_size ) {
359        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery
360        global $wpdb;
361        return $wpdb->get_col(
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            "
370        );
371        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery
372    }
373
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    /**
397     * Return the initial last sent object.
398     *
399     * @return string|array initial status.
400     */
401    public function get_initial_last_sent() {
402        return '~0';
403    }
404
405    /**
406     * Immediately send all items of a sync type as an action.
407     *
408     * @access protected
409     *
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.
414     *
415     * @return array Status, the module full sync status updated.
416     */
417    public function send_full_sync_actions( $config, $status, $send_until, $started ) {
418        global $wpdb;
419
420        if ( empty( $status['last_sent'] ) ) {
421            $status['last_sent'] = $this->get_initial_last_sent();
422        }
423
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            );
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
438        $chunks_sent = 0;
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;
452                return $status;
453            }
454
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            }
470
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;
475                return $status;
476            }
477        }
478
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.
503        }
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 );
566        return $status;
567    }
568
569    /**
570     * Immediately sends a single item without firing or enqueuing it
571     *
572     * @param string $action_name The action.
573     * @param array  $data The data associated with the action.
574     * @param string $key The key to use for the action.
575     */
576    public function send_action( $action_name, $data = null, $key = null ) {
577        $sender = Sender::get_instance();
578        return $sender->send_action( $action_name, $data, $key );
579    }
580
581    /**
582     * Retrieve chunk IDs with previous interval end.
583     *
584     * @access protected
585     *
586     * @param array $chunks                All remaining items.
587     * @param int   $previous_interval_end The last item from the previous interval.
588     * @return array Chunk IDs with the previous interval end.
589     */
590    protected function get_chunks_with_preceding_end( $chunks, $previous_interval_end ) {
591        $chunks_with_ends = array();
592        foreach ( $chunks as $chunk ) {
593            $chunks_with_ends[] = array(
594                'ids'          => $chunk,
595                'previous_end' => $previous_interval_end,
596            );
597            // Chunks are ordered in descending order.
598            $previous_interval_end = end( $chunk );
599        }
600        return $chunks_with_ends;
601    }
602
603    /**
604     * Get metadata of a particular object type within the designated meta key whitelist.
605     *
606     * @access protected
607     *
608     * @todo Refactor to use $wpdb->prepare() on the SQL query.
609     *
610     * @param array  $ids                Object IDs.
611     * @param string $meta_type          Meta type.
612     * @param array  $meta_key_whitelist Meta key whitelist.
613     * @return array Unserialized meta values.
614     */
615    protected function get_metadata( $ids, $meta_type, $meta_key_whitelist ) {
616        global $wpdb;
617        $table = _get_meta_table( $meta_type );
618        $id    = $meta_type . '_id';
619        if ( ! $table ) {
620            return array();
621        }
622
623        $private_meta_whitelist_sql = "'" . implode( "','", array_map( 'esc_sql', $meta_key_whitelist ) ) . "'";
624
625        return array_map(
626            array( $this, 'unserialize_meta' ),
627            $wpdb->get_results(
628                // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
629                "SELECT $id, meta_key, meta_value, meta_id FROM $table WHERE $id IN ( " . implode( ',', wp_parse_id_list( $ids ) ) . ' )' .
630                " AND meta_key IN ( $private_meta_whitelist_sql ) ",
631                // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
632                OBJECT
633            )
634        );
635    }
636
637    /**
638     * Initialize listeners for the particular meta type.
639     *
640     * @access public
641     *
642     * @param string   $meta_type Meta type.
643     * @param callable $callable  Action handler callable.
644     */
645    public function init_listeners_for_meta_type( $meta_type, $callable ) {
646        add_action( "added_{$meta_type}_meta", $callable, 10, 4 );
647        add_action( "updated_{$meta_type}_meta", $callable, 10, 4 );
648        add_action( "deleted_{$meta_type}_meta", $callable, 10, 4 );
649    }
650
651    /**
652     * Initialize meta whitelist handler for the particular meta type.
653     *
654     * @access public
655     *
656     * @param string   $meta_type         Meta type.
657     * @param callable $whitelist_handler Action handler callable.
658     */
659    public function init_meta_whitelist_handler( $meta_type, $whitelist_handler ) {
660        add_filter( "jetpack_sync_before_enqueue_added_{$meta_type}_meta", $whitelist_handler );
661        add_filter( "jetpack_sync_before_enqueue_updated_{$meta_type}_meta", $whitelist_handler );
662        add_filter( "jetpack_sync_before_enqueue_deleted_{$meta_type}_meta", $whitelist_handler );
663    }
664
665    /**
666     * Retrieve the term relationships for the specified object IDs.
667     *
668     * @access protected
669     *
670     * @todo This feels too specific to be in the abstract sync Module class. Move it?
671     *
672     * @param array $ids Object IDs.
673     * @return array Term relationships - object ID and term taxonomy ID pairs.
674     */
675    protected function get_term_relationships( $ids ) {
676        global $wpdb;
677
678        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
679        return $wpdb->get_results( "SELECT object_id, term_taxonomy_id FROM $wpdb->term_relationships WHERE object_id IN ( " . implode( ',', wp_parse_id_list( $ids ) ) . ' )', OBJECT );
680    }
681
682    /**
683     * Unserialize the value of a meta object, if necessary.
684     *
685     * @access public
686     *
687     * @param object $meta Meta object.
688     * @return object Meta object with possibly unserialized value.
689     */
690    public function unserialize_meta( $meta ) {
691        $meta->meta_value = maybe_unserialize( $meta->meta_value );
692        return $meta;
693    }
694
695    /**
696     * Retrieve a set of objects by their IDs.
697     *
698     * @access public
699     *
700     * @param string $object_type Object type.
701     * @param array  $ids         Object IDs.
702     * @return array Array of objects.
703     */
704    public function get_objects_by_id( $object_type, $ids ) {
705        if ( empty( $ids ) || empty( $object_type ) ) {
706            return array();
707        }
708
709        $objects = array();
710        foreach ( (array) $ids as $id ) {
711            $object = $this->get_object_by_id( $object_type, $id );
712
713            // Only add object if we have the object.
714            if ( $object ) {
715                $objects[ $id ] = $object;
716            }
717        }
718
719        return $objects;
720    }
721
722    /**
723     * Gets a list of minimum and maximum object ids for each batch based on the given batch size.
724     *
725     * @access public
726     *
727     * @param int         $batch_size The batch size for objects.
728     * @param string|bool $where_sql  The sql where clause minus 'WHERE', or false if no where clause is needed.
729     *
730     * @return array|bool An array of min and max ids for each batch. FALSE if no table can be found.
731     */
732    public function get_min_max_object_ids_for_batches( $batch_size, $where_sql = false ) {
733
734        if ( ! $this->table() ) {
735            return false;
736        }
737
738        $results      = array();
739        $table        = $this->table();
740        $current_max  = 0;
741        $current_min  = 1;
742        $id_field     = $this->id_field();
743        $replicastore = new Replicastore();
744
745        $total = $replicastore->get_min_max_object_id(
746            $id_field,
747            $table,
748            $where_sql,
749            false
750        );
751
752        while ( $total->max > $current_max ) {
753            $where  = $where_sql ?
754                $where_sql . " AND $id_field > $current_max" :
755                "$id_field > $current_max";
756            $result = $replicastore->get_min_max_object_id(
757                $id_field,
758                $table,
759                $where,
760                $batch_size
761            );
762            if ( empty( $result->min ) && empty( $result->max ) ) {
763                // Our query produced no min and max. We can assume the min from the previous query,
764                // and the total max we found in the initial query.
765                $current_max = (int) $total->max;
766                $result      = (object) array(
767                    'min' => $current_min,
768                    'max' => $current_max,
769                );
770            } else {
771                $current_min = (int) $result->min;
772                $current_max = (int) $result->max;
773            }
774            $results[] = $result;
775        }
776
777        return $results;
778    }
779
780    /**
781     * Return Total number of objects.
782     *
783     * @param array $config Full Sync config.
784     *
785     * @return int total
786     */
787    public function total( $config ) {
788        global $wpdb;
789        $table = $this->table();
790        $where = $this->get_where_sql( $config );
791
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" );
794    }
795
796    /**
797     * Retrieve the WHERE SQL clause based on the module config.
798     *
799     * @access public
800     *
801     * @param array $config Full sync configuration for this sync module.
802     * @return string WHERE SQL clause, or `null` if no comments are specified in the module config.
803     */
804    public function get_where_sql( $config ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
805        return '1=1';
806    }
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    }
868}