owner( Options::key_id() ); if ( $owner <= 0 || ! user_can( $owner, 'manage_woocommerce' ) ) { self::refuse( 'sync_no_user', 'Product sync stopped: the user who connected the store no longer exists or can no longer manage WooCommerce.' ); return; } if ( ! ProductSyncLock::acquire() ) { Log::info( 'Product sync run skipped: another run holds the lock.' ); return; } $outcome = 'stop'; try { // Read with no user and product-read rights only (SyncPermissions), never as the owner or any administrator. self::$skipped = 0; try { $outcome = SyncPermissions::run_catalog_read( static function (): string { $seo = SeoAdapters::detect(); $started = microtime( true ); do { $outcome = self::push( $seo ); ProductSyncLock::refresh(); if ( 'more' === $outcome && self::$pause_ms > 0 ) { usleep( (int) self::$pause_ms * 1000 ); } } while ( 'more' === $outcome && microtime( true ) - $started < self::$budget ); return $outcome; } ); } finally { self::log_skipped(); } } finally { ProductSyncLock::release(); } // Only once the lock is free: a chained run that found it still held would send nothing. if ( 'more' === $outcome ) { ProductSync::chain(); } } catch ( \Throwable $e ) { self::refuse( 'sync_failed', 'Product sync failed: ' . $e->getMessage() ); } } /** * Sends one request. * * @return string 'more' (send another), 'done' (nothing left) or 'stop' (not accepted). */ public static function push( SeoAdapter $seo ): string { $cursor = Options::sync_cursor(); $rows = self::modified_after( $cursor ); $batch = array_slice( $rows, 0, self::BATCH ); $deleted = array_slice( Options::deleted_product_ids(), 0, self::MAX_DELETED ); // The first sync always sends, so even an empty catalog reaches the backend as complete. if ( null !== $cursor && array() === $batch && array() === $deleted ) { return 'done'; } $products = array(); $bytes = 0; $last = null; $consumed = 0; foreach ( $batch as $row ) { $id = (int) $row['ID']; try { $entry = 'publish' === $row['post_status'] ? ProductPayload::build( $id, $seo ) : ProductPayload::stub( $id, (string) $row['post_status'] ); $size = strlen( (string) wp_json_encode( $entry ) ); } catch ( \Throwable $e ) { $entry = null; $size = 0; self::skip( $id, $e->getMessage() ); } if ( null !== $entry && $size > self::$max_product_bytes ) { $entry = null; self::skip( $id, sprintf( '%d bytes, over the request limit on its own', $size ) ); } if ( null !== $entry ) { if ( array() !== $products && $bytes + $size > self::$max_product_bytes ) { break; // Goes in the next request. } $bytes += $size; $products[] = $entry; } $last = $row; ++$consumed; } $complete = count( $rows ) <= self::BATCH && count( $batch ) === $consumed; if ( null !== $last ) { $sent = self::sent_through( (string) $last['post_modified_gmt'], (int) $last['ID'] ); } elseif ( null !== $cursor ) { $sent = self::sent_through( $cursor['modified'], $cursor['id'] ); } else { $sent = 0; } // Only skipped products, nothing deleted, more to come: move past them without a request. $send = array() !== $products || array() !== $deleted || $complete; if ( $send && ! self::post( self::body( $products, $deleted, $complete, $sent ) ) ) { return 'stop'; } if ( null !== $last ) { Options::save_sync_cursor( (string) $last['post_modified_gmt'], (int) $last['ID'] ); } elseif ( null === $cursor ) { Options::save_sync_cursor( self::ORIGIN, 0 ); } Options::forget_deleted_product_ids( $deleted ); $error = Options::last_error(); if ( null !== $error && 0 === strpos( $error['code'], 'sync_' ) ) { Options::clear_last_error(); } return $complete && array() === Options::deleted_product_ids() ? 'done' : 'more'; } /** * @param array> $products Product entries. * @param array $deleted Deleted product ids. * @param bool $complete Whether this request empties the backlog. * @param int $sent Products the cursor has passed once this request is accepted. */ public static function body( array $products, array $deleted, bool $complete, int $sent ): string { return SignedClient::body( array( 'products' => $products, 'deleted_ids' => $deleted, 'complete' => $complete, 'catalog_total' => self::catalog_total(), 'sent' => $sent, ), 2 ); } /** * @return bool Whether the backend accepted the request. */ public static function post( string $body ): bool { $response = SignedClient::post( '/webhooks/woo/products', $body, self::TIMEOUT ); if ( $response->ok() ) { return true; } if ( SignedResponse::RETRY === $response->kind ) { Log::warning( 0 === $response->status ? 'Product sync not delivered, retrying next run: ' . $response->error : 'Product sync answered HTTP ' . $response->status . ', retrying next run.' ); return false; } self::refuse( 'sync_refused', 'CortexCommerce refused the product sync: HTTP ' . $response->status . '.' ); return false; } /** * How many products the store has in every status except auto-draft: the "Y" of "X of Y", and what a complete * sync has passed. Unpublished ones are counted too; they travel as stubs. */ private static function catalog_total(): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- must be fresh; wp_count_posts() caches for the request. return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'product' AND post_status <> 'auto-draft'" ); } /** * How many products the cursor has passed once it stands at ($modified, $id): the "X" of "X of Y". */ private static function sent_through( string $modified, int $id ): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- must be fresh, and WP_Query cannot express the (modified, id) cursor. return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'product' AND post_status <> 'auto-draft' AND ( post_modified_gmt < %s OR ( post_modified_gmt = %s AND ID <= %d ) )", $modified, $modified, $id ) ); } /** * Products modified after the cursor, oldest first, one more than a batch (to tell whether this batch is the * last). Products saved during the current second wait for the next run: one saved later in this same second * with a lower id would otherwise sort behind the cursor and be missed. * * @param array{modified: string, id: int}|null $cursor The cursor. * @return array> */ private static function modified_after( ?array $cursor ): array { global $wpdb; $modified = null === $cursor ? self::ORIGIN : $cursor['modified']; $id = null === $cursor ? 0 : $cursor['id']; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- WP_Query cannot express the (modified, id) cursor; the rows must be fresh. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_status, post_modified_gmt FROM {$wpdb->posts} WHERE post_type = 'product' AND post_status <> 'auto-draft' AND ( post_modified_gmt > %s OR ( post_modified_gmt = %s AND ID > %d ) ) AND post_modified_gmt < %s ORDER BY post_modified_gmt ASC, ID ASC LIMIT %d", $modified, $modified, $id, gmdate( 'Y-m-d H:i:s' ), self::BATCH + 1 ), ARRAY_A ); return is_array( $rows ) ? $rows : array(); } /** * One summary per run, after the per-product warnings. */ private static function log_skipped(): void { if ( self::$skipped > 0 ) { Log::warning( sprintf( 'Product sync skipped %d product(s) this run; each is sent again when it is next modified.', self::$skipped ) ); } } /** * Logs why a product was left out (never its data) and counts it. */ private static function skip( int $id, string $reason ): void { ++self::$skipped; Log::warning( sprintf( 'Product sync skipped product %d: %s', $id, $reason ) ); } /** * Logs the detail and keeps the merchant-facing message for the CortexCommerce screen. */ private static function refuse( string $reason, string $detail ): void { Log::error( $detail ); Options::set_last_error( $reason, Messages::for_reason( $reason ) ); } }