Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.14% covered (warning)
67.14%
94 / 140
20.00% covered (danger)
20.00%
4 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Lookup_Data_Backfill
67.63% covered (warning)
67.63%
94 / 139
20.00% covered (danger)
20.00%
4 / 20
259.80
0.00% covered (danger)
0.00%
0 / 1
 register
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 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-premium-analytics
6 */
7
8namespace Automattic\Jetpack\PremiumAnalytics\Sync;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Automattic\Jetpack\PremiumAnalytics\Analytics;
13use Automattic\Jetpack\Sync\Actions;
14use Automattic\Jetpack\Sync\Modules;
15use DateTimeImmutable;
16use Exception;
17use Jetpack_Options;
18
19defined( '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 */
28class 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}