Commit e76a374189b for woocommerce

commit e76a374189bf880ee5f5cdeae3d39b8ed98e8fac
Author: Barry Hughes <3594411+barryhughes@users.noreply.github.com>
Date:   Thu Oct 29 15:40:29 2026 -0700

    Migrate the WooCommerce bootstrap scheduling calls to ActionSchedulerUtil (#70154)

    The recurring actions, the tracking event and the hold-stock cancel schedule go through the util, so unique is honoured on every loaded copy. add_recurring_action_wrappers() is attached through when_ready() instead of action_scheduler_init, which copies before 3.6.0 never fire. The tracker interval from wp_get_schedules() is cast to an integer before it reaches the typed util.

    Split from #69847 (WOOPLUG-2435).

    Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/update-wooplug-2435-migrate-as-calls-bootstrap b/plugins/woocommerce/changelog/update-wooplug-2435-migrate-as-calls-bootstrap
new file mode 100644
index 00000000000..015a2bb07cd
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-wooplug-2435-migrate-as-calls-bootstrap
@@ -0,0 +1,3 @@
+Significance: patch
+Type: update
+Comment: Covered by the base PR changelog entry (#69846).
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 85a2b024b14..23bb45c8336 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -31,6 +31,7 @@ use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Registe
 use Automattic\WooCommerce\Internal\ProductImage\MatchImageBySKU;
 use Automattic\WooCommerce\Internal\RestockRefundedItemsAdjuster;
 use Automattic\WooCommerce\Internal\Settings\OptionSanitizer;
+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
 use Automattic\WooCommerce\Internal\Utilities\LegacyRestApiStub;
 use Automattic\WooCommerce\Internal\Utilities\WebhookUtil;
 use Automattic\WooCommerce\Internal\Admin\EmailImprovements\EmailImprovements;
@@ -374,7 +375,7 @@ final class WooCommerce {
 		add_action( 'update_option_woocommerce_allow_tracking', array( $this, 'get_tracking_history' ), 10, 2 );
 		add_action( 'update_option_woocommerce_allow_tracking', array( $this, 'handle_tracking_setting_change' ), 10, 2 );
 		add_action( 'action_scheduler_ensure_recurring_actions', array( $this, 'register_recurring_actions' ) );
-		add_action( 'action_scheduler_init', array( $this, 'add_recurring_action_wrappers' ) );
+		ActionSchedulerUtil::when_ready( array( $this, 'add_recurring_action_wrappers' ) );

 		add_filter( 'robots_txt', array( $this, 'robots_txt' ) );
 		add_filter( 'wp_plugin_dependencies_slug', array( $this, 'convert_woocommerce_slug' ) );
@@ -1761,7 +1762,7 @@ final class WooCommerce {
 			$scheduled_sales_time = strtotime( '00:00 tomorrow' );
 		}

-		as_schedule_recurring_action( $scheduled_sales_time, DAY_IN_SECONDS, 'woocommerce_scheduled_sales', array(), 'woocommerce', true );
+		ActionSchedulerUtil::schedule_recurring( $scheduled_sales_time, DAY_IN_SECONDS, 'woocommerce_scheduled_sales', array(), 'woocommerce', true );

 		$held_duration = get_option( 'woocommerce_hold_stock_minutes', '60' );

@@ -1773,7 +1774,7 @@ final class WooCommerce {
 			 */
 			$cancel_unpaid_interval = apply_filters( 'woocommerce_cancel_unpaid_orders_interval_minutes', absint( $held_duration ) );

-			as_schedule_single_action( time() + ( absint( $cancel_unpaid_interval ) * 60 ), 'woocommerce_cancel_unpaid_orders', array(), 'woocommerce', true );
+			ActionSchedulerUtil::schedule_single( time() + ( absint( $cancel_unpaid_interval ) * 60 ), 'woocommerce_cancel_unpaid_orders', array(), 'woocommerce', true );

 		}

@@ -1790,23 +1791,23 @@ final class WooCommerce {
 		// so it doesn't occur in the same request. WooCommerce Admin also schedules
 		// a daily cron that gets lost due to a race condition. WC_Privacy's background
 		// processing instance updates the cron schedule from within a cron job.
-		as_schedule_recurring_action( time() + 10, DAY_IN_SECONDS, 'woocommerce_cleanup_personal_data', array(), 'woocommerce', true );
+		ActionSchedulerUtil::schedule_recurring( time() + 10, DAY_IN_SECONDS, 'woocommerce_cleanup_personal_data', array(), 'woocommerce', true );

-		as_schedule_recurring_action( $tomorrow_3am, DAY_IN_SECONDS, 'woocommerce_cleanup_logs', array(), 'woocommerce', true );
+		ActionSchedulerUtil::schedule_recurring( $tomorrow_3am, DAY_IN_SECONDS, 'woocommerce_cleanup_logs', array(), 'woocommerce', true );

-		as_schedule_recurring_action( $tomorrow_6am, 12 * HOUR_IN_SECONDS, 'woocommerce_cleanup_sessions', array(), 'woocommerce', true );
+		ActionSchedulerUtil::schedule_recurring( $tomorrow_6am, 12 * HOUR_IN_SECONDS, 'woocommerce_cleanup_sessions', array(), 'woocommerce', true );

-		as_schedule_recurring_action( $tomorrow_6am, 15 * DAY_IN_SECONDS, 'woocommerce_geoip_updater', array(), 'woocommerce', true );
+		ActionSchedulerUtil::schedule_recurring( $tomorrow_6am, 15 * DAY_IN_SECONDS, 'woocommerce_geoip_updater', array(), 'woocommerce', true );

 		// Schedule the action to send tracking events if tracking is enabled.
 		$this->schedule_tracking_action();

-		as_schedule_recurring_action( $tomorrow_3am, DAY_IN_SECONDS, 'woocommerce_cleanup_rate_limits_wrapper', array(), 'woocommerce', true );
+		ActionSchedulerUtil::schedule_recurring( $tomorrow_3am, DAY_IN_SECONDS, 'woocommerce_cleanup_rate_limits_wrapper', array(), 'woocommerce', true );

-		as_schedule_recurring_action( $tomorrow_3am, DAY_IN_SECONDS, 'wc_admin_daily_wrapper', array(), 'woocommerce', true );
+		ActionSchedulerUtil::schedule_recurring( $tomorrow_3am, DAY_IN_SECONDS, 'wc_admin_daily_wrapper', array(), 'woocommerce', true );

 		// Note: this is potentially redundant when the core package exists.
-		as_schedule_single_action( time() + 10, 'generate_category_lookup_table_wrapper', array(), 'woocommerce', true );
+		ActionSchedulerUtil::schedule_single( time() + 10, 'generate_category_lookup_table_wrapper', array(), 'woocommerce', true );
 	}

 	/**
@@ -1849,8 +1850,8 @@ final class WooCommerce {
 		$tracker_recurrence = apply_filters( 'woocommerce_tracker_event_recurrence', 'daily' );
 		$tracker_recurrence = is_string( $tracker_recurrence ) ? $tracker_recurrence : 'daily';
 		$core_internals     = wp_get_schedules();
-		$interval           = $core_internals[ $tracker_recurrence ]['interval'] ?? DAY_IN_SECONDS;
-		as_schedule_recurring_action( time() + 10, $interval, 'woocommerce_tracker_send_event_wrapper', array(), 'woocommerce', true );
+		$interval           = (int) ( $core_internals[ $tracker_recurrence ]['interval'] ?? DAY_IN_SECONDS );
+		ActionSchedulerUtil::schedule_recurring( time() + 10, $interval, 'woocommerce_tracker_send_event_wrapper', array(), 'woocommerce', true );
 	}

 	/**
diff --git a/plugins/woocommerce/includes/wc-formatting-functions.php b/plugins/woocommerce/includes/wc-formatting-functions.php
index 387f70df552..f2651d55d56 100644
--- a/plugins/woocommerce/includes/wc-formatting-functions.php
+++ b/plugins/woocommerce/includes/wc-formatting-functions.php
@@ -10,6 +10,7 @@

 use Automattic\WooCommerce\Enums\WeightUnit;
 use Automattic\WooCommerce\Internal\Settings\OptionSanitizer;
+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
 use Automattic\WooCommerce\Utilities\I18nUtil;
 use Automattic\WooCommerce\Utilities\NumberUtil;

@@ -1245,12 +1246,13 @@ function wc_format_option_hold_stock_minutes( $value, $option, $raw_value ) {
 	$value = ! empty( $raw_value ) ? absint( $raw_value ) : ''; // Allow > 0 or set to ''.

 	$value_changed         = (string) get_option( 'woocommerce_hold_stock_minutes', '60' ) !== (string) $value;
-	$next_scheduled_action = function_exists( 'as_next_scheduled_action' )
+	$use_action_scheduler  = ActionSchedulerUtil::is_ready();
+	$next_scheduled_action = $use_action_scheduler
 		? as_next_scheduled_action( 'woocommerce_cancel_unpaid_orders', array(), 'woocommerce' )
 		: wp_next_scheduled( 'woocommerce_cancel_unpaid_orders' );

 	// Clear existing scheduled events.
-	if ( $value_changed && function_exists( 'as_unschedule_all_actions' ) ) {
+	if ( $value_changed && $use_action_scheduler ) {
 		as_unschedule_all_actions( 'woocommerce_cancel_unpaid_orders' );
 	} elseif ( $value_changed ) {
 		wp_clear_scheduled_hook( 'woocommerce_cancel_unpaid_orders' );
@@ -1266,8 +1268,8 @@ function wc_format_option_hold_stock_minutes( $value, $option, $raw_value ) {
 		 */
 		$cancel_unpaid_interval = apply_filters( 'woocommerce_cancel_unpaid_orders_interval_minutes', absint( $value ) );

-		if ( function_exists( 'as_schedule_single_action' ) ) {
-			as_schedule_single_action( time() + ( absint( $cancel_unpaid_interval ) * 60 ), 'woocommerce_cancel_unpaid_orders', array(), 'woocommerce', true );
+		if ( $use_action_scheduler ) {
+			ActionSchedulerUtil::schedule_single( time() + ( absint( $cancel_unpaid_interval ) * 60 ), 'woocommerce_cancel_unpaid_orders', array(), 'woocommerce', true );
 		} else {
 			wp_schedule_single_event( time() + ( absint( $cancel_unpaid_interval ) * 60 ), 'woocommerce_cancel_unpaid_orders' );
 		}
diff --git a/plugins/woocommerce/tests/php/includes/wc-formatting-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-formatting-functions-test.php
index c729e57d323..dcfa8547440 100644
--- a/plugins/woocommerce/tests/php/includes/wc-formatting-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-formatting-functions-test.php
@@ -353,4 +353,26 @@ class WC_Formatting_Functions_Test extends \WC_Unit_Test_Case {
 			'A blank hold stock value should remove the cancellation action.'
 		);
 	}
+
+	/**
+	 * @testdox Saving a hold stock value falls back to WP-Cron when Action Scheduler is not ready.
+	 */
+	public function test_hold_stock_value_falls_back_to_wp_cron_when_action_scheduler_is_not_ready(): void {
+		update_option( 'woocommerce_hold_stock_minutes', '60' );
+		as_unschedule_all_actions( 'woocommerce_cancel_unpaid_orders' );
+		wp_clear_scheduled_hook( 'woocommerce_cancel_unpaid_orders' );
+		$this->register_legacy_proxy_function_mocks(
+			array(
+				'function_exists' => fn( string $function_name ): bool => 'as_schedule_single_action' !== $function_name && function_exists( $function_name ),
+			)
+		);
+
+		$expected_time = time() + HOUR_IN_SECONDS;
+		wc_format_option_hold_stock_minutes( '60', array(), '60' );
+
+		$this->assertFalse( as_next_scheduled_action( 'woocommerce_cancel_unpaid_orders' ), 'Nothing should be scheduled through Action Scheduler while it is not ready.' );
+		$this->assertEqualsWithDelta( $expected_time, wp_next_scheduled( 'woocommerce_cancel_unpaid_orders' ), 1, 'The cancellation event should fall back to WP-Cron.' );
+
+		wp_clear_scheduled_hook( 'woocommerce_cancel_unpaid_orders' );
+	}
 }