Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
67.14% |
94 / 140 |
|
20.00% |
4 / 20 |
CRAP | |
0.00% |
0 / 1 |
| Lookup_Data_Backfill | |
67.63% |
94 / 139 |
|
20.00% |
4 / 20 |
259.80 | |
0.00% |
0 / 1 |
| register | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| on_sync_status_response | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
| on_dashboard_screen | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| maybe_schedule | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
10 | |||
| start_backfill | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
13 | |||
| forget_lost_run | |
94.44% |
17 / 18 |
|
0.00% |
0 / 1 |
9.01 | |||
| schedule | |
50.00% |
1 / 2 |
|
0.00% |
0 / 1 |
2.50 | |||
| wpcom_has_order_history | |
90.00% |
18 / 20 |
|
0.00% |
0 / 1 |
6.04 | |||
| find_oldest_order_product | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
| fetch_wpcom_products | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
56 | |||
| has_orders | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| has_lookup_rows | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
| table_has_rows | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| is_jetpack_connected | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| is_sync_allowed | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| is_full_sync_running | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
| get_full_sync_status | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| get_product_ids | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| start_full_sync | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| do_full_sync | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * One-time full sync that backfills the WooCommerce Analytics lookup data. |
| 4 | * |
| 5 | * @package automattic/jetpack-premium-analytics |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack\PremiumAnalytics\Sync; |
| 9 | |
| 10 | use Automattic\Jetpack\Connection\Client; |
| 11 | use Automattic\Jetpack\Connection\Manager as Connection_Manager; |
| 12 | use Automattic\Jetpack\PremiumAnalytics\Analytics; |
| 13 | use Automattic\Jetpack\Sync\Actions; |
| 14 | use Automattic\Jetpack\Sync\Modules; |
| 15 | use DateTimeImmutable; |
| 16 | use Exception; |
| 17 | use Jetpack_Options; |
| 18 | |
| 19 | defined( 'ABSPATH' ) || exit; |
| 20 | |
| 21 | /** |
| 22 | * Starts one full sync of the order lookup data and the product catalog on stores whose orders synced before |
| 23 | * Premium Analytics owned the woocommerce_analytics module, when the standalone plugin stripped the lookup data. |
| 24 | * |
| 25 | * Incremental sync and checksums never resend those orders, so WordPress.com keeps their order |
| 26 | * stats without the product, tax and coupon lookup rows. |
| 27 | */ |
| 28 | class Lookup_Data_Backfill { |
| 29 | |
| 30 | /** |
| 31 | * Option holding the backfill state: `scheduled_at` and `started_at` timestamps. Delete it to run the backfill again. |
| 32 | */ |
| 33 | const STATE_OPTION = 'jetpack_premium_analytics_lookup_backfill'; |
| 34 | |
| 35 | /** |
| 36 | * Action Scheduler hook that starts the full sync. |
| 37 | */ |
| 38 | const START_HOOK = 'jetpack_premium_analytics_lookup_backfill'; |
| 39 | |
| 40 | /** |
| 41 | * Full sync context, so the backfill can be told apart in the sync status. |
| 42 | */ |
| 43 | const FULL_SYNC_CONTEXT = 'premium_analytics_lookup_backfill'; |
| 44 | |
| 45 | /** |
| 46 | * Seconds to wait before retrying while the full sync cannot start or WordPress.com does not answer. |
| 47 | */ |
| 48 | const RETRY_DELAY = 10 * MINUTE_IN_SECONDS; |
| 49 | |
| 50 | /** |
| 51 | * Seconds after the first attempt to keep retrying. |
| 52 | */ |
| 53 | const RETRY_WINDOW = DAY_IN_SECONDS; |
| 54 | |
| 55 | /** |
| 56 | * Set by the standalone WooCommerce Analytics plugin when its first full sync finishes; it survives deactivation. |
| 57 | */ |
| 58 | const PLUGIN_FULL_SYNC_OPTION = 'woocommerce_analytics_initial_full_sync_finished'; |
| 59 | |
| 60 | /** |
| 61 | * Most product and variation IDs to list in the full sync config; above it, all posts are resent. |
| 62 | */ |
| 63 | const MAX_PRODUCT_IDS = 5000; |
| 64 | |
| 65 | /** |
| 66 | * Lookup tables the standalone plugin left out of its sync. |
| 67 | */ |
| 68 | const LOOKUP_TABLES = array( 'wc_order_product_lookup', 'wc_order_tax_lookup', 'wc_order_coupon_lookup' ); |
| 69 | |
| 70 | /** |
| 71 | * Register the hooks. |
| 72 | * |
| 73 | * @return void |
| 74 | */ |
| 75 | public function register(): void { |
| 76 | // Priority 11: Sync_Status_Tracker sets the milestone from the stored full sync status at 10. |
| 77 | add_filter( 'rest_post_dispatch', array( $this, 'on_sync_status_response' ), 11, 3 ); |
| 78 | add_action( 'current_screen', array( $this, 'on_dashboard_screen' ) ); |
| 79 | add_action( self::START_HOOK, array( $this, 'start_backfill' ) ); |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * Check for a backfill when the dashboard polls GET /jetpack/v4/sync/status, which it does until the milestone is set. |
| 84 | * |
| 85 | * @param mixed $response Result to send to the client. Usually a WP_REST_Response. |
| 86 | * @param mixed $server The REST server instance (unused). |
| 87 | * @param mixed $request The request used to generate the response. |
| 88 | * @return mixed The unchanged response. |
| 89 | */ |
| 90 | public function on_sync_status_response( $response, $server, $request ) { |
| 91 | if ( $request instanceof \WP_REST_Request |
| 92 | && Sync_Status_Tracker::SYNC_STATUS_ROUTE === $request->get_route() |
| 93 | && $response instanceof \WP_REST_Response |
| 94 | && ! $response->is_error() ) { |
| 95 | $this->maybe_schedule(); |
| 96 | } |
| 97 | |
| 98 | return $response; |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * Check for a backfill when the dashboard loads, which covers stores whose milestone was set before and no longer poll. |
| 103 | * |
| 104 | * @return void |
| 105 | */ |
| 106 | public function on_dashboard_screen(): void { |
| 107 | if ( Analytics::is_dashboard_request() ) { |
| 108 | $this->maybe_schedule(); |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | /** |
| 113 | * Schedule the backfill when an earlier analytics full sync left orders on WordPress.com without lookup data. |
| 114 | * |
| 115 | * @return void |
| 116 | */ |
| 117 | public function maybe_schedule(): void { |
| 118 | if ( Sync_Status_Tracker::lookup_data_synced() ) { |
| 119 | return; |
| 120 | } |
| 121 | |
| 122 | $state = get_option( self::STATE_OPTION ); |
| 123 | if ( false !== $state && ! $this->forget_lost_run( $state ) ) { |
| 124 | return; |
| 125 | } |
| 126 | |
| 127 | // Wait for the first analytics full sync; if it runs under Premium Analytics, the tracker marks the data synced. |
| 128 | if ( (int) get_option( Sync_Status_Tracker::INITIAL_ANALYTICS_SYNC_OPTION, 0 ) <= 0 ) { |
| 129 | return; |
| 130 | } |
| 131 | |
| 132 | // Without the standalone plugin's full sync, Premium Analytics sent every order's lookup data itself. |
| 133 | if ( (int) get_option( self::PLUGIN_FULL_SYNC_OPTION, 0 ) <= 0 || ! $this->has_orders() || ! $this->has_lookup_rows() ) { |
| 134 | update_option( Sync_Status_Tracker::LOOKUP_DATA_SYNCED_OPTION, time() ); |
| 135 | return; |
| 136 | } |
| 137 | |
| 138 | if ( ! function_exists( 'as_schedule_single_action' ) || as_has_scheduled_action( self::START_HOOK ) ) { |
| 139 | return; |
| 140 | } |
| 141 | |
| 142 | update_option( |
| 143 | self::STATE_OPTION, |
| 144 | array( |
| 145 | 'scheduled_at' => time(), |
| 146 | 'started_at' => 0, |
| 147 | ) |
| 148 | ); |
| 149 | $this->schedule( time(), 0 ); |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * Start the full sync unless WordPress.com already holds the order history, or retry later while |
| 154 | * Jetpack cannot sync, another full sync runs, or WordPress.com does not answer. |
| 155 | * |
| 156 | * @param mixed $first_attempt Time of the first attempt; 0 for this one. |
| 157 | * @return void |
| 158 | */ |
| 159 | public function start_backfill( $first_attempt = 0 ): void { |
| 160 | $first_attempt = absint( $first_attempt ); |
| 161 | if ( 0 === $first_attempt ) { |
| 162 | $first_attempt = time(); |
| 163 | } |
| 164 | $can_retry = time() < $first_attempt + self::RETRY_WINDOW; |
| 165 | |
| 166 | $state = get_option( self::STATE_OPTION ); |
| 167 | $state = is_array( $state ) ? $state : array( 'scheduled_at' => 0 ); |
| 168 | if ( Sync_Status_Tracker::lookup_data_synced() || ! empty( $state['started_at'] ) ) { |
| 169 | return; |
| 170 | } |
| 171 | |
| 172 | if ( $this->is_jetpack_connected() && $this->is_sync_allowed() && ! $this->is_full_sync_running() ) { |
| 173 | $has_history = $this->wpcom_has_order_history(); |
| 174 | if ( true === $has_history ) { |
| 175 | update_option( Sync_Status_Tracker::LOOKUP_DATA_SYNCED_OPTION, time() ); |
| 176 | return; |
| 177 | } |
| 178 | |
| 179 | // Without an answer, start anyway once the retries run out: a needless full sync beats missing history. |
| 180 | if ( false === $has_history || ! $can_retry ) { |
| 181 | // Claim the run before starting it, so a concurrent action for it does nothing. |
| 182 | update_option( self::STATE_OPTION, array_merge( $state, array( 'started_at' => time() ) ) ); |
| 183 | if ( $this->start_full_sync() ) { |
| 184 | return; |
| 185 | } |
| 186 | update_option( self::STATE_OPTION, $state ); |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | if ( $can_retry ) { |
| 191 | $this->schedule( time() + self::RETRY_DELAY, $first_attempt ); |
| 192 | return; |
| 193 | } |
| 194 | |
| 195 | // Give up on this run; the next dashboard check schedules a new one. |
| 196 | delete_option( self::STATE_OPTION ); |
| 197 | } |
| 198 | |
| 199 | /** |
| 200 | * Forget a run whose action is gone or whose full sync another one replaced, or settle one whose end the tracker missed. |
| 201 | * |
| 202 | * @param array|mixed $state Stored backfill state. |
| 203 | * @return bool Whether the run was forgotten, so a new one can be scheduled. |
| 204 | */ |
| 205 | private function forget_lost_run( $state ): bool { |
| 206 | if ( ! is_array( $state ) ) { |
| 207 | return false; |
| 208 | } |
| 209 | |
| 210 | if ( empty( $state['started_at'] ) ) { |
| 211 | // A canceled or failed action leaves nothing to start the run. |
| 212 | if ( ! function_exists( 'as_has_scheduled_action' ) || as_has_scheduled_action( self::START_HOOK ) ) { |
| 213 | return false; |
| 214 | } |
| 215 | delete_option( self::STATE_OPTION ); |
| 216 | return true; |
| 217 | } |
| 218 | |
| 219 | $status = $this->get_full_sync_status(); |
| 220 | if ( self::FULL_SYNC_CONTEXT === ( $status['context'] ?? null ) ) { |
| 221 | if ( empty( $status['finished'] ) ) { |
| 222 | return false; |
| 223 | } |
| 224 | if ( true === ( $status['progress']['woocommerce_analytics']['finished'] ?? false ) ) { |
| 225 | update_option( Sync_Status_Tracker::LOOKUP_DATA_SYNCED_OPTION, (int) $status['finished'] ); |
| 226 | return false; |
| 227 | } |
| 228 | } elseif ( $this->is_full_sync_running() ) { |
| 229 | // The replacing full sync may include the module; the tracker marks the data synced if so. |
| 230 | return false; |
| 231 | } |
| 232 | |
| 233 | delete_option( self::STATE_OPTION ); |
| 234 | return true; |
| 235 | } |
| 236 | |
| 237 | /** |
| 238 | * Schedule the start action. When Action Scheduler cannot store it, forget the run so a later check schedules it again. |
| 239 | * |
| 240 | * @param int $timestamp When the action runs. |
| 241 | * @param int $first_attempt Time of the first attempt; 0 when this schedules the first one. |
| 242 | * @return void |
| 243 | */ |
| 244 | private function schedule( int $timestamp, int $first_attempt ): void { |
| 245 | if ( ! as_schedule_single_action( $timestamp, self::START_HOOK, array( $first_attempt ) ) ) { |
| 246 | delete_option( self::STATE_OPTION ); |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | /** |
| 251 | * Whether WordPress.com reports the product of the oldest completed order, with its type from the product catalog. |
| 252 | * |
| 253 | * Only lookup rows put the product in the report, and only a synced catalog gives it a type. Orders synced after |
| 254 | * Premium Analytics took over carry lookup rows, so only an old order tells whether the history arrived. |
| 255 | * |
| 256 | * @return bool|null Null when WordPress.com did not answer. |
| 257 | */ |
| 258 | protected function wpcom_has_order_history(): ?bool { |
| 259 | $order = $this->find_oldest_order_product(); |
| 260 | if ( null === $order ) { |
| 261 | return false; |
| 262 | } |
| 263 | |
| 264 | try { |
| 265 | $created = new DateTimeImmutable( $order['date_created'], wp_timezone() ); |
| 266 | } catch ( Exception $e ) { |
| 267 | return false; |
| 268 | } |
| 269 | |
| 270 | // A day either side absorbs any time zone difference between the stored date and the report. |
| 271 | $products = $this->fetch_wpcom_products( |
| 272 | array( |
| 273 | 'from' => $created->modify( '-1 day' )->setTime( 0, 0 )->format( 'Y-m-d\TH:i:sP' ), |
| 274 | 'to' => $created->modify( '+1 day' )->setTime( 23, 59, 59 )->format( 'Y-m-d\TH:i:sP' ), |
| 275 | 'date_type' => 'created', |
| 276 | 'limit' => 100, |
| 277 | ) |
| 278 | ); |
| 279 | if ( null === $products ) { |
| 280 | return null; |
| 281 | } |
| 282 | |
| 283 | foreach ( $products as $product ) { |
| 284 | if ( (int) ( $product['product_id'] ?? 0 ) === (int) $order['product_id'] ) { |
| 285 | return ! empty( $product['product_type'] ); |
| 286 | } |
| 287 | } |
| 288 | return false; |
| 289 | } |
| 290 | |
| 291 | /** |
| 292 | * The product and creation date of the oldest completed order with product lookup rows. |
| 293 | * |
| 294 | * Completed orders are never in WordPress.com's excluded report statuses. |
| 295 | * |
| 296 | * @return array{product_id: int|string, date_created: string}|null |
| 297 | */ |
| 298 | protected function find_oldest_order_product(): ?array { |
| 299 | global $wpdb; |
| 300 | |
| 301 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Runs once per store, from the backfill action. |
| 302 | $row = $wpdb->get_row( |
| 303 | "SELECT lookup.product_id, stats.date_created |
| 304 | FROM {$wpdb->prefix}wc_order_stats stats |
| 305 | INNER JOIN {$wpdb->prefix}wc_order_product_lookup lookup ON lookup.order_id = stats.order_id |
| 306 | WHERE stats.status = 'wc-completed' AND stats.parent_id = 0 |
| 307 | ORDER BY stats.date_created ASC |
| 308 | LIMIT 1", |
| 309 | ARRAY_A |
| 310 | ); |
| 311 | |
| 312 | return is_array( $row ) ? $row : null; |
| 313 | } |
| 314 | |
| 315 | /** |
| 316 | * Fetch rows of the WordPress.com products report, signed as the blog. |
| 317 | * |
| 318 | * @param array $args Report query arguments. |
| 319 | * @return array|null Product rows, or null when the request failed. |
| 320 | */ |
| 321 | protected function fetch_wpcom_products( array $args ): ?array { |
| 322 | // add_query_arg() doesn't encode values, and the date offsets' `+` would arrive as a space. |
| 323 | $path = add_query_arg( urlencode_deep( $args ), sprintf( '/sites/%d/analytics/reports/products', (int) Jetpack_Options::get_option( 'id' ) ) ); |
| 324 | |
| 325 | try { |
| 326 | $response = Client::wpcom_json_api_request_as_blog( $path, '2', array( 'timeout' => 15 ), null, 'wpcom' ); |
| 327 | } catch ( Exception $e ) { |
| 328 | return null; |
| 329 | } |
| 330 | |
| 331 | if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { |
| 332 | return null; |
| 333 | } |
| 334 | |
| 335 | $body = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 336 | return is_array( $body ) && isset( $body['data'] ) && is_array( $body['data'] ) ? $body['data'] : null; |
| 337 | } |
| 338 | |
| 339 | /** |
| 340 | * Whether WooCommerce Analytics holds any order stats. |
| 341 | * |
| 342 | * @return bool |
| 343 | */ |
| 344 | protected function has_orders(): bool { |
| 345 | return self::table_has_rows( 'wc_order_stats' ); |
| 346 | } |
| 347 | |
| 348 | /** |
| 349 | * Whether any WooCommerce Analytics lookup table has rows. |
| 350 | * |
| 351 | * @return bool |
| 352 | */ |
| 353 | protected function has_lookup_rows(): bool { |
| 354 | foreach ( self::LOOKUP_TABLES as $table ) { |
| 355 | if ( self::table_has_rows( $table ) ) { |
| 356 | return true; |
| 357 | } |
| 358 | } |
| 359 | return false; |
| 360 | } |
| 361 | |
| 362 | /** |
| 363 | * Whether a WooCommerce table has at least one row; a missing table has none. |
| 364 | * |
| 365 | * @param string $table Table name without the prefix. |
| 366 | * @return bool |
| 367 | */ |
| 368 | private static function table_has_rows( string $table ): bool { |
| 369 | global $wpdb; |
| 370 | |
| 371 | $suppress = $wpdb->suppress_errors(); |
| 372 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from a constant list; runs until the backfill is scheduled or marked done. |
| 373 | $row = $wpdb->get_var( "SELECT 1 FROM `{$wpdb->prefix}{$table}` LIMIT 1" ); |
| 374 | $wpdb->suppress_errors( $suppress ); |
| 375 | |
| 376 | return null !== $row; |
| 377 | } |
| 378 | |
| 379 | /** |
| 380 | * Whether the site is connected to WordPress.com. |
| 381 | * |
| 382 | * @return bool |
| 383 | */ |
| 384 | protected function is_jetpack_connected(): bool { |
| 385 | return ( new Connection_Manager() )->is_connected(); |
| 386 | } |
| 387 | |
| 388 | /** |
| 389 | * Whether Jetpack allows syncing (not offline, in safe mode, or with sync disabled). |
| 390 | * |
| 391 | * @return bool |
| 392 | */ |
| 393 | protected function is_sync_allowed(): bool { |
| 394 | return Actions::sync_allowed(); |
| 395 | } |
| 396 | |
| 397 | /** |
| 398 | * Whether another full sync is running. |
| 399 | * |
| 400 | * @return bool |
| 401 | */ |
| 402 | protected function is_full_sync_running(): bool { |
| 403 | $module = Modules::get_module( 'full-sync' ); |
| 404 | if ( ! $module ) { |
| 405 | return false; |
| 406 | } |
| 407 | '@phan-var \Automattic\Jetpack\Sync\Modules\Full_Sync_Immediately|\Automattic\Jetpack\Sync\Modules\Full_Sync $module'; |
| 408 | |
| 409 | return $module->is_started() && ! $module->is_finished(); |
| 410 | } |
| 411 | |
| 412 | /** |
| 413 | * The stored full sync status. |
| 414 | * |
| 415 | * @return array |
| 416 | */ |
| 417 | protected function get_full_sync_status(): array { |
| 418 | $module = Modules::get_module( 'full-sync' ); |
| 419 | if ( ! $module ) { |
| 420 | return array(); |
| 421 | } |
| 422 | '@phan-var \Automattic\Jetpack\Sync\Modules\Full_Sync_Immediately|\Automattic\Jetpack\Sync\Modules\Full_Sync $module'; |
| 423 | |
| 424 | return (array) $module->get_status(); |
| 425 | } |
| 426 | |
| 427 | /** |
| 428 | * IDs of all products and variations. |
| 429 | * |
| 430 | * @return int[] |
| 431 | */ |
| 432 | protected function get_product_ids(): array { |
| 433 | global $wpdb; |
| 434 | |
| 435 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Runs once per store, from the backfill action. |
| 436 | return array_map( 'intval', $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' ) ORDER BY ID" ) ); |
| 437 | } |
| 438 | |
| 439 | /** |
| 440 | * Start the full sync of the order lookup data and the product catalog. |
| 441 | * |
| 442 | * WordPress.com builds its product table from synced product posts and their product_type terms, which it |
| 443 | * ignored before it knew an analytics plugin was active. Modules are sent in this order: terms before posts. |
| 444 | * |
| 445 | * @return bool Whether Jetpack started it. |
| 446 | */ |
| 447 | private function start_full_sync(): bool { |
| 448 | $config = array( 'woocommerce_analytics' => true ); |
| 449 | $product_ids = $this->get_product_ids(); |
| 450 | if ( $product_ids ) { |
| 451 | // term_relationships can't be limited to IDs; posts can, and only products build the catalog. Full sync rewrites its |
| 452 | // status, config included, after every batch, so a large catalog resends all posts, which are then mostly products. |
| 453 | $config += array( |
| 454 | 'terms' => true, |
| 455 | 'term_relationships' => true, |
| 456 | 'posts' => count( $product_ids ) > self::MAX_PRODUCT_IDS ? true : $product_ids, |
| 457 | ); |
| 458 | } |
| 459 | |
| 460 | return $this->do_full_sync( $config ); |
| 461 | } |
| 462 | |
| 463 | /** |
| 464 | * Start a full sync with the backfill's context. |
| 465 | * |
| 466 | * @param array $config Full sync config by module name. |
| 467 | * @return bool Whether Jetpack started it. |
| 468 | */ |
| 469 | protected function do_full_sync( array $config ): bool { |
| 470 | return Actions::do_full_sync( $config, self::FULL_SYNC_CONTEXT ); |
| 471 | } |
| 472 | } |