Commit 481932123f for woocommerce
commit 481932123f386279bdb3f71d95a2701220d3b7bb
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date: Wed Nov 26 11:49:20 2025 +0900
Fix duplicate recurring actions in OrdersScheduler and handle import mode transitions (#61907)
* Refactor OrdersScheduler to support immediate and recurring imports
- Introduced a conditional check for immediate import functionality, allowing for either immediate scheduling of imports or recurring batch processing based on the configuration.
- Added a new action to handle changes to the immediate import option, ensuring proper scheduling of imports during configuration changes.
- Updated the fallback date for cursor validation to the last 24 hours for improved accuracy.
- Cleaned up legacy code related to immediate import checks and scheduling logic.
* Refactor immediate import handling in OrdersScheduler
- Reorganized the logic for scheduling immediate catchup batches during configuration changes to ensure no orders are missed.
- Moved the scheduling of the immediate catchup batch to occur after unscheduling the recurring batch processor for clarity and improved flow.
* Implement immediate import check in OrdersScheduler
- Added a conditional check to return the order ID early if immediate import is not enabled, improving the clarity of the scheduling logic.
- This change ensures that the function only processes orders when immediate import functionality is active, enhancing performance and maintainability.
* Add changelog
* Update plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update tests
* Fix lint
* Update plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Add handling for immediate import option in OrdersScheduler
- Introduced methods to handle addition and deletion of the immediate import option, ensuring proper state management.
- Set a default value for the immediate import option to streamline its usage.
- Updated the logic to check the immediate import option consistently across the class, enhancing maintainability.
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/fix-order-scheduler-batch-import b/plugins/woocommerce/changelog/fix-order-scheduler-batch-import
new file mode 100644
index 0000000000..71630039d0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-order-scheduler-batch-import
@@ -0,0 +1,5 @@
+Significance: patch
+Type: update
+Comment: Fix order schedule batch import - the feature is not released yet
+
+
diff --git a/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php b/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
index 9a871f7694..d4a75fe3e3 100644
--- a/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
+++ b/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
@@ -59,6 +59,13 @@ class OrdersScheduler extends ImportScheduler {
*/
const IMMEDIATE_IMPORT_OPTION = 'woocommerce_analytics_immediate_import';
+ /**
+ * Default value for the immediate import option.
+ *
+ * @var string
+ */
+ const IMMEDIATE_IMPORT_OPTION_DEFAULT_VALUE = 'yes';
+
/**
* Attach order lookup update hooks.
*
@@ -69,14 +76,20 @@ class OrdersScheduler extends ImportScheduler {
\Automattic\WooCommerce\Admin\Overrides\Order::add_filters();
\Automattic\WooCommerce\Admin\Overrides\OrderRefund::add_filters();
- // Legacy behavior: Schedule import immediately on order create/update/delete.
- add_action( 'woocommerce_update_order', array( __CLASS__, 'possibly_schedule_import' ) );
- add_filter( 'woocommerce_create_order', array( __CLASS__, 'possibly_schedule_import' ) );
- add_action( 'woocommerce_refund_created', array( __CLASS__, 'possibly_schedule_import' ) );
- add_action( 'woocommerce_schedule_import', array( __CLASS__, 'possibly_schedule_import' ) );
-
- // Schedule recurring batch processor.
- add_action( 'init', array( __CLASS__, 'schedule_recurring_batch_processor' ) );
+ if ( self::is_immediate_import_enabled() ) {
+ // Schedule import immediately on order create/update/delete.
+ add_action( 'woocommerce_update_order', array( __CLASS__, 'possibly_schedule_import' ) );
+ add_filter( 'woocommerce_create_order', array( __CLASS__, 'possibly_schedule_import' ) );
+ add_action( 'woocommerce_refund_created', array( __CLASS__, 'possibly_schedule_import' ) );
+ add_action( 'woocommerce_schedule_import', array( __CLASS__, 'possibly_schedule_import' ) );
+ } else {
+ // Schedule recurring batch processor.
+ add_action( 'action_scheduler_ensure_recurring_actions', array( __CLASS__, 'schedule_recurring_batch_processor' ) );
+ }
+ // Watch for changes to the immediate import option.
+ add_action( 'add_option_' . self::IMMEDIATE_IMPORT_OPTION, array( __CLASS__, 'handle_immediate_import_option_added' ), 10, 2 );
+ add_action( 'update_option_' . self::IMMEDIATE_IMPORT_OPTION, array( __CLASS__, 'handle_immediate_import_option_change' ), 10, 2 );
+ add_action( 'delete_option', array( __CLASS__, 'handle_immediate_import_option_before_delete' ), 10, 1 );
OrdersStatsDataStore::init();
CouponsDataStore::init();
@@ -357,52 +370,94 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
* @internal
*/
public static function schedule_recurring_batch_processor() {
- if ( self::is_immediate_import_enabled() ) {
- // No need to schedule if immediate import is enabled.
- if ( self::has_existing_jobs( 'process_pending_batch', array() ) ) {
- $action_hook = self::get_action( 'process_pending_batch' );
- as_unschedule_all_actions( $action_hook, array(), static::$group );
- }
+ $action_hook = self::get_action( 'process_pending_batch' );
+ // The most efficient way to check for an existing action is to use `as_has_scheduled_action`, but in unusual
+ // cases where another plugin has loaded a very old version of Action Scheduler, it may not be available to us.
+ $has_scheduled_action = function_exists( 'as_has_scheduled_action' ) ? 'as_has_scheduled_action' : 'as_next_scheduled_action';
+ if ( call_user_func( $has_scheduled_action, $action_hook ) ) {
return;
}
- // Initialize last processed date if not set.
- self::initialize_last_processed_date();
/**
* Filters the interval for the recurring batch processor.
*
- * @since 9.5.0
+ * @since 10.4.0
* @param int $interval Interval in seconds. Default 12 hours.
*/
$interval = apply_filters( 'woocommerce_analytics_import_interval', 12 * HOUR_IN_SECONDS );
- $action_hook = self::get_action( 'process_pending_batch' );
+ as_schedule_recurring_action( time(), $interval, $action_hook, array(), static::$group, true );
+ }
- // Schedule recurring action if not already scheduled.
- if ( ! self::has_existing_jobs( 'process_pending_batch', array() ) ) {
- self::queue()->schedule_recurring( time(), $interval, $action_hook, array(), static::$group );
+ /**
+ * Handle changes to the immediate import option.
+ *
+ * When switching from batch processing to immediate import,
+ * we need to run a final catchup batch to ensure no orders are missed.
+ *
+ * When switching from immediate import to batch processing,
+ * we need to reschedule the recurring batch processor.
+ *
+ * @internal
+ * @param mixed $old_value The old value of the option.
+ * @param mixed $new_value The new value of the option.
+ * @return void
+ */
+ public static function handle_immediate_import_option_change( $old_value, $new_value ) {
+ // If switching from batch processing to immediate import.
+ if ( 'no' === $old_value && 'yes' === $new_value ) {
+ // Unschedule the recurring batch processor.
+ $action_hook = self::get_action( 'process_pending_batch' );
+ as_unschedule_all_actions( $action_hook, array(), static::$group );
+
+ // Schedule an immediate catchup batch to process all orders up to now.
+ // This ensures no orders are missed during the transition.
+ self::schedule_action( 'process_pending_batch', array( null, null ) );
+ } elseif ( 'yes' === $old_value && 'no' === $new_value ) {
+ // Switching from immediate import to batch processing.
+ // Set the last processed order date to now with 1 minute buffer to ensure no orders are missed.
+ update_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, gmdate( 'Y-m-d H:i:s', time() - MINUTE_IN_SECONDS ) );
+ update_option( self::LAST_PROCESSED_ORDER_ID_OPTION, 0 );
+
+ // Schedule the recurring batch processor.
+ self::schedule_recurring_batch_processor();
}
}
/**
- * Initialize the last processed date option if not set.
+ * Handle addition of the immediate import option.
*
* @internal
+ * @param string $option_name The name of the option that was added.
+ * @param string $value The value of the option that was added.
+ *
+ * @return void
*/
- private static function initialize_last_processed_date() {
- if ( false !== get_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, false ) ) {
- return; // Already initialized.
+ public static function handle_immediate_import_option_added( $option_name, $value ) {
+ if ( self::IMMEDIATE_IMPORT_OPTION !== $option_name ) {
+ return;
}
- /**
- * Add buffer to ensure orders created or updated during plugin activation, upgrade, or prior to import are accounted for.
- * Buffer in seconds. 10 minutes.
- */
- $buffer_seconds = 10 * MINUTE_IN_SECONDS;
- $start_date = gmdate( 'Y-m-d H:i:s', time() - $buffer_seconds );
+ self::handle_immediate_import_option_change( self::IMMEDIATE_IMPORT_OPTION_DEFAULT_VALUE, $value );
+ }
+
+ /**
+ * Handle deletion of the immediate import option.
+ *
+ * @internal
+ * @param string $option_name The name of the option that was deleted.
+ *
+ * @return void
+ */
+ public static function handle_immediate_import_option_before_delete( $option_name ) {
+ if ( self::IMMEDIATE_IMPORT_OPTION !== $option_name ) {
+ return;
+ }
- update_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, $start_date, false );
- update_option( self::LAST_PROCESSED_ORDER_ID_OPTION, 0, false );
+ self::handle_immediate_import_option_change(
+ get_option( self::IMMEDIATE_IMPORT_OPTION, self::IMMEDIATE_IMPORT_OPTION_DEFAULT_VALUE ),
+ self::IMMEDIATE_IMPORT_OPTION_DEFAULT_VALUE,
+ );
}
/**
@@ -427,13 +482,15 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
}
// Load cursor position from options if not provided.
- $cursor_date = $cursor_date ?? get_option( self::LAST_PROCESSED_ORDER_DATE_OPTION );
- $cursor_id = $cursor_id ?? (int) get_option( self::LAST_PROCESSED_ORDER_ID_OPTION, 0 );
+ // If the cursor date is not provided, use the last 24 hours as the default since `action_scheduler_ensure_recurring_actions` runs daily so 24 hours is enough.
+ $default_cursor_date = gmdate( 'Y-m-d H:i:s', strtotime( '-24 hours' ) );
+ $cursor_date = $cursor_date ?? get_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, $default_cursor_date );
+ $cursor_id = $cursor_id ?? (int) get_option( self::LAST_PROCESSED_ORDER_ID_OPTION, 0 );
// Validate cursor date.
if ( ! $cursor_date || ! strtotime( $cursor_date ) ) {
$logger->error( 'Invalid cursor date: ' . $cursor_date, $context );
- $cursor_date = gmdate( 'Y-m-d H:i:s', 0 ); // Fallback to the earliest possible date.
+ $cursor_date = $default_cursor_date;
}
$batch_size = self::get_batch_size( 'process_pending_batch' );
@@ -628,7 +685,6 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
* @return bool
*/
private static function is_immediate_import_enabled(): bool {
- $enable_immediate_import = get_option( self::IMMEDIATE_IMPORT_OPTION, true );
- return filter_var( $enable_immediate_import, FILTER_VALIDATE_BOOLEAN );
+ return 'no' !== get_option( self::IMMEDIATE_IMPORT_OPTION, self::IMMEDIATE_IMPORT_OPTION_DEFAULT_VALUE );
}
}
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 da84a01939..ec66777d33 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
@@ -23,133 +23,194 @@ class OrdersSchedulerTest extends WC_Unit_Test_Case {
delete_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION );
delete_option( OrdersScheduler::LAST_PROCESSED_ORDER_ID_OPTION );
delete_option( OrdersScheduler::IMMEDIATE_IMPORT_OPTION );
+
+ // Clean up any scheduled actions.
+ $this->clear_scheduled_batch_processor();
}
/**
- * Test that batch processor is NOT scheduled when immediate import is enabled.
+ * Test that batch processor is scheduled when called.
*/
- public function test_batch_processor_not_scheduled_when_immediate_import_enabled() {
- // Enable immediate import.
- add_option( OrdersScheduler::IMMEDIATE_IMPORT_OPTION, true );
+ public function test_batch_processor_scheduled() {
+ // Clear any existing scheduled actions.
+ $this->clear_scheduled_batch_processor();
OrdersScheduler::schedule_recurring_batch_processor();
- // Verify the last processed date was NOT initialized (batch processor skipped).
- $this->assertFalse(
- get_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION ),
- 'Last processed date should not be initialized when immediate import is enabled'
+ // Verify the recurring action is scheduled.
+ $this->assertTrue(
+ $this->is_batch_processor_scheduled(),
+ 'Batch processor should be scheduled'
);
}
/**
- * Test that batch processor IS scheduled when immediate import is disabled.
+ * Test that batch processor is not scheduled twice.
*/
- public function test_batch_processor_scheduled_when_immediate_import_disabled() {
- // Disable immediate import (enable batch mode).
- add_option( OrdersScheduler::IMMEDIATE_IMPORT_OPTION, false );
+ public function test_batch_processor_not_scheduled_twice() {
+ // Clear any existing scheduled actions.
+ $this->clear_scheduled_batch_processor();
+ $action_hook = OrdersScheduler::get_action( 'process_pending_batch' );
+
+ // Schedule first time.
+ OrdersScheduler::schedule_recurring_batch_processor();
+ // Try to schedule again.
OrdersScheduler::schedule_recurring_batch_processor();
- // Verify the last processed date WAS initialized (batch processor scheduled).
- $this->assertNotFalse(
- get_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION ),
- 'Last processed date should be initialized when batch mode is enabled'
+ // Verify it's still the same scheduled time (not rescheduled).
+ $second_scheduled = as_get_scheduled_actions(
+ array(
+ 'hook' => $action_hook,
+ 'args' => array(),
+ 'group' => OrdersScheduler::$group,
+ 'status' => 'pending',
+ 'per_page' => 1,
+ ),
+ ARRAY_A
);
+ $this->assertCount( 1, $second_scheduled, 'Batch processor should be scheduled once' );
}
/**
- * Test that the batch processor action is registered.
+ * Test that import interval filter is applied.
*/
- public function test_get_scheduler_actions_includes_batch_processor() {
- $actions = OrdersScheduler::get_scheduler_actions();
+ public function test_import_interval_filter_is_applied() {
+ // Clear any existing scheduled actions.
+ $this->clear_scheduled_batch_processor();
- $this->assertArrayHasKey(
- 'process_pending_batch',
- $actions,
- 'Scheduler actions should include process_pending_batch'
+ $custom_interval = 6 * HOUR_IN_SECONDS;
+ $filter_called = false;
+ add_filter(
+ 'woocommerce_analytics_import_interval',
+ function () use ( $custom_interval, &$filter_called ) {
+ $filter_called = true;
+ return $custom_interval;
+ }
);
- $this->assertEquals(
- 'wc-admin_process_pending_orders_batch',
- $actions['process_pending_batch'],
- 'process_pending_batch action should map to correct hook name'
+ // This will trigger the filter.
+ OrdersScheduler::schedule_recurring_batch_processor();
+
+ // Verify filter was applied.
+ $this->assertTrue(
+ $filter_called,
+ 'Import interval filter should be applied when scheduling batch processor'
);
}
/**
- * Test that last processed date is initialized correctly.
+ * Test that handle_immediate_import_option_change unschedules batch processor when switching to immediate import.
*/
- public function test_initialize_sets_last_processed_date() {
- // Disable immediate import to enable batch mode.
- add_option( OrdersScheduler::IMMEDIATE_IMPORT_OPTION, false );
+ public function test_handle_immediate_import_option_change_unschedules_batch_when_enabling_immediate() {
+ // Clear any existing scheduled actions.
+ $this->clear_scheduled_batch_processor();
+ // Schedule the batch processor first.
OrdersScheduler::schedule_recurring_batch_processor();
+ $this->assertTrue(
+ $this->is_batch_processor_scheduled(),
+ 'Batch processor should be scheduled initially'
+ );
- $last_date = get_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION );
+ // Switch from batch processing ('no') to immediate import ('yes').
+ OrdersScheduler::handle_immediate_import_option_change( 'no', 'yes' );
+
+ // Verify the batch processor is unscheduled.
+ $this->assertFalse(
+ $this->is_batch_processor_scheduled(),
+ 'Batch processor should be unscheduled when switching to immediate import'
+ );
+ }
+
+ /**
+ * Test that handle_immediate_import_option_change schedules batch processor when switching from immediate import.
+ */
+ public function test_handle_immediate_import_option_change_schedules_batch_when_disabling_immediate() {
+ // Clear any existing scheduled actions.
+ $this->clear_scheduled_batch_processor();
+
+ // Switch from immediate import ('yes') to batch processing ('no').
+ OrdersScheduler::handle_immediate_import_option_change( 'yes', 'no' );
+
+ // Verify the batch processor is scheduled.
+ $this->assertTrue(
+ $this->is_batch_processor_scheduled(),
+ 'Batch processor should be scheduled when switching from immediate import to batch processing'
+ );
+ // Verify the last processed date is set to approximately 1 minute ago.
+ $last_date = get_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION );
$this->assertNotFalse(
$last_date,
- 'Last processed date option should be set after initialization'
+ 'Last processed date should be set when switching to batch processing'
);
- // Should be approximately 10 minutes ago (600 seconds).
- $expected_timestamp = time() - ( 10 * MINUTE_IN_SECONDS );
+ $expected_timestamp = time() - MINUTE_IN_SECONDS;
$actual_timestamp = strtotime( $last_date );
$this->assertEqualsWithDelta(
$expected_timestamp,
$actual_timestamp,
5,
- 'Last processed date should be approximately 10 minutes ago'
+ 'Last processed date should be approximately 1 minute ago'
+ );
+
+ // Verify the last processed order ID is reset to 0.
+ $last_id = get_option( OrdersScheduler::LAST_PROCESSED_ORDER_ID_OPTION );
+ $this->assertEquals(
+ 0,
+ $last_id,
+ 'Last processed order ID should be reset to 0'
);
}
/**
- * Test that last processed date is not re-initialized if already set.
+ * Test that handle_immediate_import_option_change does nothing for other transitions.
*/
- public function test_initialize_does_not_overwrite_existing_date() {
- $existing_date = '2024-01-01 12:00:00';
- add_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION, $existing_date );
-
- // Disable immediate import to enable batch mode.
- add_option( OrdersScheduler::IMMEDIATE_IMPORT_OPTION, false );
+ public function test_handle_immediate_import_option_change_ignores_other_transitions() {
+ // Clear any existing scheduled actions.
+ $this->clear_scheduled_batch_processor();
- OrdersScheduler::schedule_recurring_batch_processor();
+ $action_hook = OrdersScheduler::get_action( 'process_pending_batch' );
- $last_date = get_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION );
+ // Test transition from 'yes' to 'yes' (no change).
+ OrdersScheduler::handle_immediate_import_option_change( 'yes', 'yes' );
+ $this->assertFalse(
+ $this->is_batch_processor_scheduled(),
+ 'Batch processor should not be scheduled when option stays as immediate import'
+ );
+ // Test transition from 'no' to 'no' (no change).
+ OrdersScheduler::schedule_recurring_batch_processor();
+ $scheduled_time = as_next_scheduled_action( $action_hook );
+ OrdersScheduler::handle_immediate_import_option_change( 'no', 'no' );
$this->assertEquals(
- $existing_date,
- $last_date,
- 'Last processed date should not be overwritten if already set'
+ $scheduled_time,
+ as_next_scheduled_action( $action_hook ),
+ 'Batch processor should remain scheduled when option stays as batch processing'
);
}
/**
- * Test that import interval filter is applied.
+ * Clear any scheduled batch processor actions.
+ *
+ * @return void
*/
- public function test_import_interval_filter_is_applied() {
- $custom_interval = 6 * HOUR_IN_SECONDS;
- $filter_called = false;
- add_filter(
- 'woocommerce_analytics_import_interval',
- function () use ( $custom_interval, &$filter_called ) {
- $filter_called = true;
- return $custom_interval;
- }
- );
-
- // Enable batch mode (disable immediate import).
- add_option( OrdersScheduler::IMMEDIATE_IMPORT_OPTION, false );
-
- // This will trigger the filter.
- OrdersScheduler::schedule_recurring_batch_processor();
+ private function clear_scheduled_batch_processor(): void {
+ $action_hook = OrdersScheduler::get_action( 'process_pending_batch' );
+ if ( function_exists( 'as_unschedule_all_actions' ) ) {
+ as_unschedule_all_actions( $action_hook, array(), OrdersScheduler::$group );
+ }
+ }
- // Verify filter was applied (we can't directly check ActionScheduler without complex mocking,
- // but we can verify the filter is called by checking if it was applied).
- $this->assertTrue(
- $filter_called,
- 'Import interval filter should be applied during initialization'
- );
+ /**
+ * Check if the batch processor action is scheduled.
+ *
+ * @return bool
+ */
+ private function is_batch_processor_scheduled(): bool {
+ $action_hook = OrdersScheduler::get_action( 'process_pending_batch' );
+ return function_exists( 'as_has_scheduled_action' ) ? as_has_scheduled_action( $action_hook, array(), OrdersScheduler::$group ) : (bool) as_next_scheduled_action( $action_hook, array(), OrdersScheduler::$group );
}
}