Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
16.75% covered (danger)
16.75%
32 / 191
36.36% covered (danger)
36.36%
8 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Full_Sync_Immediately
16.40% covered (danger)
16.40%
31 / 189
36.36% covered (danger)
36.36%
8 / 22
3016.12
0.00% covered (danger)
0.00%
0 / 1
 name
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
 start
56.52% covered (warning)
56.52%
13 / 23
0.00% covered (danger)
0.00%
0 / 1
13.26
 is_started
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_status
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 get_sync_progress_percentage
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 is_finished
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 reset_data
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 clear_status
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 update_status
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_status
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_initial_progress
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 get_content_range
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 get_range
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 continue_enqueuing
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 continue_sending
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 send
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
156
 get_remaining_modules_to_send
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 maybe_send_full_sync_start
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 maybe_send_cancelled_action
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 send_full_sync_end
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 update_sent_progress_action
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Full sync module.
4 *
5 * @package automattic/jetpack-sync
6 */
7
8namespace Automattic\Jetpack\Sync\Modules;
9
10use Automattic\Jetpack\Sync\Actions;
11use Automattic\Jetpack\Sync\Defaults;
12use Automattic\Jetpack\Sync\Lock;
13use Automattic\Jetpack\Sync\Modules;
14use Automattic\Jetpack\Sync\Settings;
15
16if ( ! defined( 'ABSPATH' ) ) {
17    exit( 0 );
18}
19
20/**
21 * This class does a full resync of the database by
22 * sending an outbound action for every single object
23 * that we care about.
24 */
25class Full_Sync_Immediately extends Module {
26    /**
27     * Prefix of the full sync status option name.
28     *
29     * @var string
30     */
31    const STATUS_OPTION = 'jetpack_sync_full_status';
32
33    /**
34     * Sync Lock name.
35     *
36     * @var string
37     */
38    const LOCK_NAME = 'full_sync';
39
40    /**
41     * Sync module name.
42     *
43     * @access public
44     *
45     * @return string
46     */
47    public function name() {
48        return 'full-sync';
49    }
50
51    /**
52     * Initialize action listeners for full sync.
53     *
54     * @access public
55     *
56     * @param callable $callable Action handler callable.
57     */
58    public function init_full_sync_listeners( $callable ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
59    }
60
61    /**
62     * Start a full sync.
63     *
64     * @access public
65     *
66     * @param array $full_sync_config Full sync configuration.
67     * @param mixed $context The context where the full sync was initiated from.
68     *
69     * @return bool Always returns true at success.
70     */
71    public function start( $full_sync_config = null, $context = null ) {
72        // Check if there was a full sync in progress already before resetting the data.
73        $should_process_cancelled_action = $this->get_status()['start_action_processed'] && ! $this->is_finished() ? true : false;
74        // Remove all evidence of previous full sync items and status.
75        $this->reset_data();
76
77        // Update status to indicate that a new full sync is starting and need to cancel previous one.
78        if ( $should_process_cancelled_action ) {
79            $this->update_status(
80                array(
81                    'cancelled_action_processed' => false,
82                )
83            );
84        }
85
86        if ( ! is_array( $full_sync_config ) ) {
87            /*
88             * Filter default sync config to allow injecting custom configuration.
89             *
90             * @param array $full_sync_config Sync configuration for all sync modules.
91             *
92             * @since 3.10.0
93             */
94            $full_sync_config = apply_filters( 'jetpack_full_sync_config', Defaults::$default_full_sync_config );
95            if ( is_multisite() ) {
96                $full_sync_config['network_options'] = 1;
97            }
98        }
99
100        if ( isset( $full_sync_config['users'] ) && 'initial' === $full_sync_config['users'] ) {
101            $users_module = Modules::get_module( 'users' );
102            '@phan-var Users $users_module';
103            $full_sync_config['users'] = $users_module->get_initial_sync_user_config();
104        }
105
106        $this->update_status(
107            array(
108                'started' => time(),
109                'config'  => $full_sync_config,
110                'context' => $context,
111            )
112        );
113
114        return true;
115    }
116
117    /**
118     * Whether full sync has started.
119     *
120     * @access public
121     *
122     * @return boolean
123     */
124    public function is_started() {
125        return (bool) $this->get_status()['started'];
126    }
127
128    /**
129     * Retrieve the status of the current full sync.
130     *
131     * @access public
132     *
133     * @return array Full sync status.
134     */
135    public function get_status() {
136        $default = array(
137            'start_action_processed'     => false,
138            'cancelled_action_processed' => true, // true by default to avoid sending the action when there is no need,
139            'started'                    => false,
140            'finished'                   => false,
141            'progress'                   => array(),
142            'config'                     => array(),
143            'context'                    => null,
144        );
145
146        return wp_parse_args( \Jetpack_Options::get_raw_option( self::STATUS_OPTION ), $default );
147    }
148
149    /**
150     * Returns the progress percentage of a full sync.
151     *
152     * @access public
153     *
154     * @return int|null
155     */
156    public function get_sync_progress_percentage() {
157        if ( ! $this->is_started() || $this->is_finished() ) {
158            return null;
159        }
160        $status = $this->get_status();
161        if ( empty( $status['progress'] ) ) {
162            return null;
163        }
164        $total_items = array_reduce(
165            array_values( $status['progress'] ),
166            function ( $sum, $sync_item ) {
167                return isset( $sync_item['total'] ) ? ( $sum + (int) $sync_item['total'] ) : $sum;
168            },
169            0
170        );
171        $total_sent  = array_reduce(
172            array_values( $status['progress'] ),
173            function ( $sum, $sync_item ) {
174                return isset( $sync_item['sent'] ) ? ( $sum + (int) $sync_item['sent'] ) : $sum;
175            },
176            0
177        );
178        return floor( ( $total_sent / $total_items ) * 100 );
179    }
180
181    /**
182     * Whether full sync has finished.
183     *
184     * @access public
185     *
186     * @return boolean
187     */
188    public function is_finished() {
189        return (bool) $this->get_status()['finished'];
190    }
191
192    /**
193     * Clear all the full sync data.
194     *
195     * @access public
196     */
197    public function reset_data() {
198        $this->clear_status();
199        ( new Lock() )->remove( self::LOCK_NAME, true );
200    }
201
202    /**
203     * Clear all the full sync status options.
204     *
205     * @access public
206     */
207    public function clear_status() {
208        \Jetpack_Options::delete_raw_option( self::STATUS_OPTION );
209    }
210
211    /**
212     * Updates the status of the current full sync.
213     *
214     * @access public
215     *
216     * @param array $values New values to set.
217     *
218     * @return bool True if success.
219     */
220    public function update_status( $values ) {
221        return $this->set_status( wp_parse_args( $values, $this->get_status() ) );
222    }
223
224    /**
225     * Retrieve the status of the current full sync.
226     *
227     * @param array $values New values to set.
228     *
229     * @access public
230     *
231     * @return boolean Full sync status.
232     */
233    public function set_status( $values ) {
234        return \Jetpack_Options::update_raw_option( self::STATUS_OPTION, $values );
235    }
236
237    /**
238     * Given an initial Full Sync configuration get the initial status.
239     *
240     * @param array $full_sync_config Full sync configuration.
241     * @param array $range Range of the sync items, containing min, max and count IDs for some item types.
242     *
243     * @return array Initial Sent status.
244     */
245    public function get_initial_progress( $full_sync_config, $range = null ) {
246        // Set default configuration, calculate totals, and save configuration if totals > 0.
247        $status = array();
248        foreach ( $full_sync_config as $name => $config ) {
249            $module = Modules::get_module( $name );
250            if ( ! $module ) {
251                continue;
252            }
253            $status[ $name ] = array(
254                // If we have a range for the module, use the count from the range to avoid querying the database again.
255                'total'    => $range[ $name ]->count ?? $module->total( $config ),
256                'sent'     => 0,
257                'finished' => false,
258            );
259        }
260
261        return $status;
262    }
263
264    /**
265     * Get the range for content (posts and comments) to sync.
266     *
267     * @access private
268     *
269     * @param array $full_sync_config Full sync configuration.
270     *
271     * @return array Array of range (min ID, max ID, total items) for all content types.
272     */
273    private function get_content_range( $full_sync_config ) {
274        $range = array();
275        foreach ( $full_sync_config as $module_name => $config ) {
276            // Calculate ranges only for modules that get chunked.
277            if ( in_array( $module_name, array( 'constants', 'functions', 'network_options', 'options', 'themes', 'updates' ), true ) ) {
278                continue;
279            }
280            $module = Modules::get_module( $module_name );
281            if ( ! $module ) {
282                continue;
283            }
284            if ( true === isset( $config ) && $config ) {
285                $range[ $module_name ] = $this->get_range( $module_name );
286            }
287        }
288
289        return $range;
290    }
291
292    /**
293     * Get the range (min ID, max ID and total items) of items to sync.
294     *
295     * @access public
296     *
297     * @param string $type Type of sync item to get the range for.
298     *
299     * @return array Array of min ID, max ID and total items in the range.
300     */
301    public function get_range( $type ) {
302        global $wpdb;
303        $module = Modules::get_module( $type );
304        if ( ! $module ) {
305            return array();
306        }
307
308        $table = $module->table();
309        $id    = $module->id_field();
310        if ( 'terms' === $module ) { // Terms module relies on the term_taxonomy and term_taxonomy_id for the where sql, let's use term_id instead.
311            $id = 'term_id';
312        }
313        $where_sql = $module->get_where_sql( array() );
314
315        // TODO: Call $wpdb->prepare on the following query.
316        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
317        $results = $wpdb->get_results( "SELECT MAX({$id}) as max, MIN({$id}) as min, COUNT({$id}) as count FROM {$table} WHERE {$where_sql}" );
318        if ( isset( $results[0] ) ) {
319            return $results[0];
320        }
321
322        return array();
323    }
324
325    /**
326     * Continue sending instead of enqueueing.
327     *
328     * @access public
329     */
330    public function continue_enqueuing() {
331        $this->continue_sending();
332    }
333
334    /**
335     * Continue sending.
336     *
337     * @access public
338     */
339    public function continue_sending() {
340        // Return early if Full Sync is not running.
341        if ( ! $this->is_started() || $this->get_status()['finished'] ) {
342            return;
343        }
344
345        // Return early if we've gotten a retry-after header response.
346        $retry_time = get_option( Actions::RETRY_AFTER_PREFIX . 'immediate-send' );
347        if ( $retry_time ) {
348            // If expired delete but don't send. Send will occurr in new request to avoid race conditions.
349            if ( microtime( true ) > $retry_time ) {
350                update_option( Actions::RETRY_AFTER_PREFIX . 'immediate-send', false, false );
351            }
352            return false;
353        }
354
355        // Obtain send Lock.
356        $lock            = new Lock();
357        $lock_expiration = $lock->attempt( self::LOCK_NAME );
358
359        // Return if unable to obtain lock.
360        if ( false === $lock_expiration ) {
361            return;
362        }
363
364        // Send Full Sync actions.
365        $success = $this->send();
366
367        // Remove lock.
368        if ( $success ) {
369            $lock->remove( self::LOCK_NAME, $lock_expiration );
370        }
371    }
372
373    /**
374     * Immediately send the next items to full sync.
375     *
376     * @access public
377     */
378    public function send() {
379
380        if ( ! $this->maybe_send_cancelled_action() ) {
381            return false;
382        }
383
384        if ( ! $this->maybe_send_full_sync_start() ) {
385            return false;
386        }
387        $config = $this->get_status()['config'];
388
389        $max_duration = Settings::get_setting( 'full_sync_send_duration' );
390        $send_until   = microtime( true ) + $max_duration;
391
392        $progress = $this->get_status()['progress'];
393
394        $started = $this->get_status()['started'];
395
396        $remaining_modules = $this->get_remaining_modules_to_send();
397
398        foreach ( $remaining_modules as $module ) {
399            $module_name = $module->name();
400            if ( array_key_exists( $module_name, $progress ) && array_key_exists( $module_name, $config ) ) {
401                $progress[ $module_name ] = $module->send_full_sync_actions( $config[ $module_name ], $progress[ $module_name ], $send_until, $started );
402                if ( isset( $progress[ $module_name ]['error'] ) ) {
403                    unset( $progress[ $module_name ]['error'] );
404                    $this->update_status( array( 'progress' => $progress ) );
405                    return false;
406                } elseif ( ! $progress[ $module_name ]['finished'] ) {
407                    $this->update_status( array( 'progress' => $progress ) );
408                    return true;
409                }
410            }
411
412            if ( $this->get_status()['started'] !== $started ) {
413                // Full sync was restarted, stop sending.
414                return false;
415            }
416        }
417
418        // Check that all remaining modules in progress are actually finished.
419        // If a module was skipped in the main loop (due to being unfinished), but still exists in progress, we shouldn't mark the sync as complete.
420        foreach ( $remaining_modules as $module ) {
421            $name = $module->name();
422            if ( array_key_exists( $name, $progress ) && empty( $progress[ $name ]['finished'] ) ) {
423                $this->update_status( array( 'progress' => $progress ) );
424                return true;
425            }
426        }
427
428        $this->send_full_sync_end();
429        $this->update_status( array( 'progress' => $progress ) );
430        return true;
431    }
432
433    /**
434     * Get Modules that are configured to Full Sync and haven't finished sending
435     *
436     * @return array
437     */
438    public function get_remaining_modules_to_send() {
439        $status            = $this->get_status();
440        $remaining_modules = array();
441        foreach ( array_keys( $status['config'] ) as $module_name ) {
442            $module = Modules::get_module( $module_name );
443            if ( ! $module ) {
444                continue;
445            }
446            if ( isset( $status['progress'][ $module_name ]['finished'] ) &&
447                true === $status['progress'][ $module_name ]['finished'] ) {
448                    continue;
449            }
450            // Ensure that 'constants', 'options', and 'callables' are sent first.
451            if ( in_array( $module_name, array( 'network_options', 'options', 'functions', 'constants' ), true ) ) {
452                array_unshift( $remaining_modules, $module );
453            } else {
454                $remaining_modules[] = $module;
455            }
456        }
457        return $remaining_modules;
458    }
459
460    /**
461     * Sends the `jetpack_full_sync_start` action if it hasn't been processed yet.
462     *
463     * Prepares the full sync start action, sends it to WordPress.com, fires the local action,
464     * and updates the sync status to reflect that the start action has been processed.
465     *
466     * @return bool True if the action was successfully sent or already processed, false on failure.
467     */
468    private function maybe_send_full_sync_start() {
469        $status = $this->get_status();
470
471        // If already processed, nothing to do.
472        if ( true === $status['start_action_processed'] ) {
473            return true;
474        }
475
476        $config  = $status['config'];
477        $context = $status['context'];
478        $range   = $this->get_content_range( $config );
479
480        $result = $this->send_action( 'jetpack_full_sync_start', array( $config, $range, $context ) );
481
482        // If the action failed on WordPress.com, return false.
483        if ( is_wp_error( $result ) ) {
484            return false;
485        }
486
487        /**
488         * Fires when a full sync begins. This action is serialized
489         * and sent to the server so that it knows a full sync is coming.
490         *
491         * @param array $config Sync configuration for all sync modules.
492         * @param array $range Range of the sync items, containing min and max IDs for some item types.
493         * @param mixed $context The context where the full sync was initiated from.
494         *
495         * @since 1.6.3
496         * @since-jetpack 4.2.0
497         * @since-jetpack 7.3.0 Added $range arg.
498         * @since 4.4.0 Added $context arg.
499         */
500        do_action( 'jetpack_full_sync_start', $config, $range );
501
502        $this->update_status(
503            array(
504                'start_action_processed' => true,
505                'progress'               => $this->get_initial_progress( $config, $range ),
506            )
507        );
508
509        return true;
510    }
511
512    /**
513     * Sends the `jetpack_full_sync_cancelled` action if it hasn't been processed yet.
514     *
515     * @return bool True if the action was successfully sent or already processed, false on failure.
516     */
517    private function maybe_send_cancelled_action() {
518        $status = $this->get_status();
519
520        if ( true === $status['cancelled_action_processed'] ) {
521            return true;
522        }
523
524        $result = $this->send_action( 'jetpack_full_sync_cancelled' );
525
526        if ( is_wp_error( $result ) ) {
527            return false;
528        }
529
530        /**
531         * Fires when a full sync is cancelled.
532         *
533         * @since 1.6.3
534         * @since-jetpack 4.2.0
535         */
536        do_action( 'jetpack_full_sync_cancelled' );
537        $this->update_status( array( 'cancelled_action_processed' => true ) );
538        return true;
539    }
540
541    /**
542     *  Sends the `jetpack_full_sync_end` action and updates the status when the full sync end action is processed.
543     *
544     * @access public
545     */
546    public function send_full_sync_end() {
547        $status  = $this->get_status();
548        $range   = $this->get_content_range( $status['config'] );
549        $context = $status['context'];
550
551        $result = $this->send_action( 'jetpack_full_sync_end', array( '', $range, $context ) );
552
553        if ( is_wp_error( $result ) ) { // Do not set finished status if we get an error.
554            return;
555        }
556        /**
557         * Fires when a full sync ends. This action is serialized
558         * and sent to the server.
559         *
560         * @param string $checksum Deprecated since 7.3.0 - @see https://github.com/Automattic/jetpack/pull/11945/
561         * @param array $range Range of the sync items, containing min and max IDs for some item types.
562         *
563         * @since 1.6.3
564         * @since-jetpack 4.2.0
565         * @since-jetpack 7.3.0 Added $range arg.
566         */
567        do_action( 'jetpack_full_sync_end', '', $range );
568
569        // Setting autoload to true means that it's faster to check whether we should continue enqueuing.
570        $this->update_status( array( 'finished' => time() ) );
571    }
572
573    /**
574     * Empty Function as we don't close buffers on Immediate Full Sync.
575     *
576     * @param array $actions an array of actions, ignored for queueless sync.
577     */
578    public function update_sent_progress_action( $actions ) { } // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
579}