Commit 0ff4425fa9f for woocommerce

commit 0ff4425fa9ff5a4cb1fbbdf004b924ef0eea2448
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Mon Jun 1 20:04:02 2026 +0800

    Improve analytics import failure diagnostics (#64588)

    * fix: log analytics import failures

    * chore: add changelog for analytics import diagnostics

    * Fix pending batch to skip failing orders and advance cursor

    - process_pending_batch now logs the error and advances the cursor past
      the failing order instead of rethrowing, preventing a single bad order
      from permanently halting the recurring Action Scheduler pipeline
    - Declare ImportScheduler::$name to resolve 13 baselined PHPStan errors;
      remove the now-obsolete baseline entry
    - Add @throws \Throwable to import_batch docblock to fix PHPCS lint error
    - Rewrite the throwable test to assert skip-and-advance behaviour and add
      a second test verifying the cursor lands on the last-processed order
      when a later order fails

    * Fix next-batch scheduling suppressed by failed orders

diff --git a/plugins/woocommerce/changelog/fix-analytics-import-failure-diagnostics b/plugins/woocommerce/changelog/fix-analytics-import-failure-diagnostics
new file mode 100644
index 00000000000..49335e6fbcd
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-analytics-import-failure-diagnostics
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Improve diagnostics for failed analytics order imports.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 8a0e932fe2a..21152626014 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -60645,12 +60645,6 @@ parameters:
 			count: 1
 			path: src/Internal/Admin/Schedulers/ImportInterface.php

-		-
-			message: '#^Access to an undefined static property static\(Automattic\\WooCommerce\\Internal\\Admin\\Schedulers\\ImportScheduler\)\:\:\$name\.$#'
-			identifier: staticProperty.notFound
-			count: 13
-			path: src/Internal/Admin/Schedulers/ImportScheduler.php
-
 		-
 			message: '#^Call to an undefined static method Automattic\\WooCommerce\\Internal\\Admin\\Schedulers\\ImportScheduler\:\:delete\(\)\.$#'
 			identifier: staticMethod.notFound
diff --git a/plugins/woocommerce/src/Internal/Admin/Schedulers/ImportScheduler.php b/plugins/woocommerce/src/Internal/Admin/Schedulers/ImportScheduler.php
index 9922d19b5ad..8a7e60019ad 100644
--- a/plugins/woocommerce/src/Internal/Admin/Schedulers/ImportScheduler.php
+++ b/plugins/woocommerce/src/Internal/Admin/Schedulers/ImportScheduler.php
@@ -19,6 +19,13 @@ abstract class ImportScheduler implements ImportInterface {
 	 */
 	const IMPORT_STATS_OPTION = 'woocommerce_admin_import_stats';

+	/**
+	 * Scheduler name. Subclasses must override this with a concrete value.
+	 *
+	 * @var string
+	 */
+	public static $name = '';
+
 	/**
 	 * Scheduler traits.
 	 */
@@ -119,6 +126,7 @@ abstract class ImportScheduler implements ImportInterface {
 	 * @param int|bool $days Number of days to import.
 	 * @param bool     $skip_existing Skip existing records.
 	 * @return void
+	 * @throws \Throwable Re-throws any error from a failed item import after logging diagnostics.
 	 */
 	public static function import_batch( $batch_number, $days, $skip_existing ) {
 		$batch_size = static::get_batch_size( 'import' );
@@ -136,7 +144,12 @@ abstract class ImportScheduler implements ImportInterface {
 		$items = static::get_items( $batch_size, $page, $days, $skip_existing );

 		foreach ( $items->ids as $id ) {
-			static::import( $id );
+			try {
+				static::import( $id );
+			} catch ( \Throwable $e ) {
+				static::log_import_error( $id, $e );
+				throw $e;
+			}
 		}

 		$import_stats                              = get_option( self::IMPORT_STATS_OPTION, array() );
@@ -149,6 +162,46 @@ abstract class ImportScheduler implements ImportInterface {
 		wc_admin_record_tracks_event( 'import_job_complete', $properties );
 	}

+	/**
+	 * Log details for an item that failed analytics import.
+	 *
+	 * @internal
+	 * @param int        $item_id Import item ID.
+	 * @param \Throwable $error   Error thrown during import.
+	 * @param array      $context Additional logger context.
+	 * @return void
+	 */
+	final protected static function log_import_error( $item_id, \Throwable $error, array $context = array() ) {
+		$logger      = wc_get_logger();
+		$log_context = array_merge(
+			array(
+				'source' => 'wc-analytics-import',
+			),
+			$context,
+			array(
+				'scheduler'       => static::$name,
+				'item_id'         => (int) $item_id,
+				'exception_class' => get_class( $error ),
+				'exception_file'  => $error->getFile(),
+				'exception_line'  => $error->getLine(),
+				'trace'           => $error->getTraceAsString(),
+			)
+		);
+
+		$logger->error(
+			sprintf(
+				'Failed to import analytics item %1$d for %2$s: [%3$s] %4$s in %5$s:%6$d',
+				$item_id,
+				static::$name,
+				get_class( $error ),
+				$error->getMessage(),
+				$error->getFile(),
+				$error->getLine()
+			),
+			$log_context
+		);
+	}
+
 	/**
 	 * Queue item deletion in batches.
 	 *
diff --git a/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php b/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
index ef94a004b7c..742be51c494 100644
--- a/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
+++ b/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
@@ -541,6 +541,7 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 			return;
 		}

+		$orders_count    = count( $orders );
 		$processed_count = 0;
 		foreach ( $orders as $order ) {
 			try {
@@ -549,15 +550,14 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )

 				// Advance cursor after each successful import. Since orders are sorted by
 				// date ASC, id ASC, we can simply overwrite with the current order's values.
-				// If an error occurs, we break and save the last successful position.
 				$cursor_date = $order->date_updated_gmt;
 				$cursor_id   = $order->id;
-			} catch ( \Exception $e ) {
-				$logger->error(
-					sprintf( 'Failed to import order %d: %s', $order->id, $e->getMessage() ),
-					$context
-				);
-				break;
+			} catch ( \Throwable $e ) {
+				// Log the failure and advance the cursor past the failing order so that
+				// it is skipped on the next run rather than blocking the entire pipeline.
+				static::log_import_error( $order->id, $e, $context );
+				$cursor_date = $order->date_updated_gmt;
+				$cursor_id   = $order->id;
 			}
 		}

@@ -568,8 +568,9 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 		$elapsed_time = microtime( true ) - $start_time;
 		$logger->info(
 			sprintf(
-				'Batch import completed. Processed: %d orders in %.2f seconds. Cursor: %s (ID: %d)',
+				'Batch import completed. Processed: %d/%d orders in %.2f seconds. Cursor: %s (ID: %d)',
 				$processed_count,
+				$orders_count,
 				$elapsed_time,
 				$cursor_date,
 				$cursor_id
@@ -577,9 +578,10 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 			$context
 		);

-		// If we got a full batch, there might be more orders to process.
-		// Schedule immediate next batch.
-		if ( $processed_count === $batch_size ) {
+		// If we fetched a full batch, there might be more orders to process.
+		// Use the fetched count rather than successful count so that skipped
+		// failing orders do not suppress scheduling of the next batch.
+		if ( $orders_count === $batch_size ) {
 			$logger->info( 'Full batch processed, scheduling next batch', $context );
 			self::schedule_action(
 				'process_pending_batch',
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
index 8ccfc9c123c..ca9312329be 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
@@ -388,6 +388,74 @@ class OrdersSchedulerTest extends WC_Unit_Test_Case {
 		$this->assertSame( -1, $result );
 	}

+	/**
+	 * @testdox process_pending_batch skips a failing order and advances the cursor past it.
+	 */
+	public function test_process_pending_batch_skips_failing_order_and_advances_cursor(): void {
+		global $wpdb;
+		// Anchor the cursor just before test orders so existing DB orders are excluded.
+		$cursor_id = (int) $wpdb->get_var( "SELECT MAX(id) FROM {$wpdb->prefix}wc_orders" );
+
+		$order = \WC_Helper_Order::create_order();
+		$order->set_status( 'completed' );
+		$order->save();
+
+		$cursor_date     = '2000-01-01 00:00:00';
+		$throwing_filter = function ( $is_test, $checked_order ) use ( $order ) {
+			if ( $checked_order instanceof \WC_Abstract_Order && $checked_order->get_id() === $order->get_id() ) {
+				throw new \DivisionByZeroError( 'Division by zero' );
+			}
+
+			return $is_test;
+		};
+
+		OrdersScheduler::clear_queued_actions();
+		add_filter( 'woocommerce_analytics_is_test_order', $throwing_filter, 10, 2 );
+		OrdersScheduler::process_pending_batch( $cursor_date, $cursor_id );
+		remove_filter( 'woocommerce_analytics_is_test_order', $throwing_filter, 10 );
+
+		// Cursor must advance past the failing order so it is not retried on the next run.
+		$this->assertSame( $order->get_id(), (int) get_option( OrdersScheduler::LAST_PROCESSED_ORDER_ID_OPTION ) );
+		$this->assertNotSame( $cursor_date, get_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION ) );
+	}
+
+	/**
+	 * @testdox process_pending_batch advances the cursor to the last-processed order when a later order fails.
+	 */
+	public function test_process_pending_batch_cursor_reflects_last_processed_order_on_partial_failure(): void {
+		global $wpdb;
+		// Anchor the cursor just before test orders so existing DB orders are excluded.
+		$cursor_id = (int) $wpdb->get_var( "SELECT MAX(id) FROM {$wpdb->prefix}wc_orders" );
+
+		// Both orders get the same timestamp in tests; ordering falls back to id ASC,
+		// so order_a (lower ID) is processed before order_b.
+		$order_a = \WC_Helper_Order::create_order();
+		$order_a->set_status( 'completed' );
+		$order_a->save();
+
+		$order_b = \WC_Helper_Order::create_order();
+		$order_b->set_status( 'completed' );
+		$order_b->save();
+
+		$cursor_date     = '2000-01-01 00:00:00';
+		$throwing_filter = function ( $is_test, $checked_order ) use ( $order_b ) {
+			if ( $checked_order instanceof \WC_Abstract_Order && $checked_order->get_id() === $order_b->get_id() ) {
+				throw new \DivisionByZeroError( 'Division by zero' );
+			}
+
+			return $is_test;
+		};
+
+		OrdersScheduler::clear_queued_actions();
+		add_filter( 'woocommerce_analytics_is_test_order', $throwing_filter, 10, 2 );
+		OrdersScheduler::process_pending_batch( $cursor_date, $cursor_id );
+		remove_filter( 'woocommerce_analytics_is_test_order', $throwing_filter, 10 );
+
+		// Cursor should be at order_b (the last order processed, even though it failed),
+		// not at order_a or at the initial position.
+		$this->assertSame( $order_b->get_id(), (int) get_option( OrdersScheduler::LAST_PROCESSED_ORDER_ID_OPTION ) );
+	}
+
 	/**
 	 * @testdox is_scheduled_import_enabled falls back to legacy option when new option is absent.
 	 */