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