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.
*/