Commit fee7fa83408 for woocommerce
commit fee7fa834087eac6d77d922850b315829c7d68f5
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Tue Jun 16 09:28:56 2026 +0200
Prevent duplicate wc_product_start_scheduled_sale and wc_product_end_scheduled_sale actions (#65709)
diff --git a/plugins/woocommerce/changelog/63517-fix-duplicate-scheduled-sale-events b/plugins/woocommerce/changelog/63517-fix-duplicate-scheduled-sale-events
new file mode 100644
index 00000000000..1a0a3abde65
--- /dev/null
+++ b/plugins/woocommerce/changelog/63517-fix-duplicate-scheduled-sale-events
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent duplicate wc_product_start_scheduled_sale and wc_product_end_scheduled_sale actions from piling up in Action Scheduler when concurrent processes schedule sale events for the same product.
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index 5e994709d17..4226a9a8c10 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -561,38 +561,53 @@ function wc_get_formatted_variation( $variation, $flat = false, $include_names =
* Uses Action Scheduler to fire events at the exact sale start/end times,
* rather than relying on the daily cron.
*
+ * An action is not scheduled if an identical one (same hook, args, group and
+ * timestamp) is already pending, so concurrent processes saving the same
+ * product don't pile up duplicate actions.
+ *
* @since 10.5.0
* @param WC_Product $product Product object.
* @return void
*/
function wc_schedule_product_sale_events( WC_Product $product ): void {
$product_id = $product->get_id();
- $date_from = $product->get_date_on_sale_from( 'edit' );
- $date_to = $product->get_date_on_sale_to( 'edit' );
-
- if ( $date_from ) {
- $start_ts = $date_from->getTimestamp();
- if ( $start_ts > time() ) {
- as_schedule_single_action(
- $start_ts,
- 'wc_product_start_scheduled_sale',
- array( 'product_id' => $product_id ),
- 'woocommerce-sales'
- );
+
+ $schedule = function ( ?WC_DateTime $date, string $hook ) use ( $product_id ): void {
+ if ( is_null( $date ) ) {
+ return;
}
- }
- if ( $date_to ) {
- $end_ts = $date_to->getTimestamp();
- if ( $end_ts > time() ) {
- as_schedule_single_action(
- $end_ts,
- 'wc_product_end_scheduled_sale',
- array( 'product_id' => $product_id ),
- 'woocommerce-sales'
- );
+ $timestamp = $date->getTimestamp();
+ if ( $timestamp <= time() ) {
+ return;
}
- }
+
+ $args = array( 'product_id' => $product_id );
+
+ // An identical pending action means a concurrent process (parallel save, importer,
+ // daily cron) already scheduled it after the unschedule-all step ran. The query
+ // filters by the exact timestamp: a pending action for a different time (e.g. left
+ // behind by a process that saw older sale dates) must not block scheduling.
+ $identical_pending = as_get_scheduled_actions(
+ array(
+ 'hook' => $hook,
+ 'args' => $args,
+ 'group' => 'woocommerce-sales',
+ 'status' => ActionScheduler_Store::STATUS_PENDING,
+ 'date' => gmdate( 'Y-m-d H:i:s', $timestamp ),
+ 'date_compare' => '=',
+ 'per_page' => 1,
+ ),
+ 'ids'
+ );
+
+ if ( empty( $identical_pending ) ) {
+ as_schedule_single_action( $timestamp, $hook, $args, 'woocommerce-sales' );
+ }
+ };
+
+ $schedule( $product->get_date_on_sale_from( 'edit' ), 'wc_product_start_scheduled_sale' );
+ $schedule( $product->get_date_on_sale_to( 'edit' ), 'wc_product_end_scheduled_sale' );
}
/**
diff --git a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
index 3d5cf9f766d..c51ca0e1111 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -379,6 +379,112 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
$this->assertNotEquals( $original_start, $new_start_action, 'Start action should be rescheduled with new time' );
}
+ /**
+ * @testDox Identical pending sale events are not scheduled twice when scheduling runs concurrently.
+ */
+ public function test_wc_schedule_product_sale_events_skips_identical_pending_actions() {
+ $future_start = time() + 3600;
+ $future_end = time() + 86400;
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_price( 100 );
+ $product->set_regular_price( 100 );
+ $product->set_sale_price( 50 );
+ $product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', $future_start ) );
+ $product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', $future_end ) );
+ $product->save();
+
+ // Simulate a concurrent process that already passed the unschedule-all step
+ // by invoking the scheduling function directly, without unscheduling first.
+ wc_schedule_product_sale_events( wc_get_product( $product->get_id() ) );
+
+ $this->assertSame(
+ 1,
+ $this->count_pending_sale_actions( 'wc_product_start_scheduled_sale', $product->get_id() ),
+ 'Only one start sale action should be pending after concurrent scheduling'
+ );
+ $this->assertSame(
+ 1,
+ $this->count_pending_sale_actions( 'wc_product_end_scheduled_sale', $product->get_id() ),
+ 'Only one end sale action should be pending after concurrent scheduling'
+ );
+ }
+
+ /**
+ * @testDox An identical pending sale event is not scheduled twice even when an earlier action is also pending.
+ */
+ public function test_wc_schedule_product_sale_events_skips_identical_pending_action_behind_an_earlier_one() {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_price( 100 );
+ $product->set_regular_price( 100 );
+ $product->set_sale_price( 50 );
+ $product->save();
+
+ $earlier_ts = time() + 3600;
+ $target_ts = time() + 7200;
+
+ // Simulate a stale action left behind by a concurrent process that saw older sale dates.
+ as_schedule_single_action( $earlier_ts, 'wc_product_start_scheduled_sale', array( 'product_id' => $product->get_id() ), 'woocommerce-sales' );
+
+ // Not saved on purpose: saving would trigger the unschedule-all step and remove the stale action.
+ $product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', $target_ts ) );
+
+ wc_schedule_product_sale_events( $product );
+ wc_schedule_product_sale_events( $product );
+
+ $this->assertSame(
+ 2,
+ $this->count_pending_sale_actions( 'wc_product_start_scheduled_sale', $product->get_id() ),
+ 'The action at the new time should be scheduled exactly once alongside the stale earlier action'
+ );
+ }
+
+ /**
+ * @testDox A sale event pending for a different time does not prevent scheduling at the new time.
+ */
+ public function test_wc_schedule_product_sale_events_schedules_when_pending_action_has_different_timestamp() {
+ $future_start = time() + 3600;
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_price( 100 );
+ $product->set_regular_price( 100 );
+ $product->set_sale_price( 50 );
+ $product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', $future_start ) );
+ $product->save();
+
+ $product = wc_get_product( $product->get_id() );
+ $product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', $future_start + 3600 ) );
+ wc_schedule_product_sale_events( $product );
+
+ $this->assertSame(
+ 2,
+ $this->count_pending_sale_actions( 'wc_product_start_scheduled_sale', $product->get_id() ),
+ 'A start sale action with a different timestamp should still be scheduled'
+ );
+ }
+
+ /**
+ * Count pending Action Scheduler sale actions for a product.
+ *
+ * @param string $hook Action hook name.
+ * @param int $product_id Product ID.
+ * @return int
+ */
+ private function count_pending_sale_actions( string $hook, int $product_id ): int {
+ $actions = as_get_scheduled_actions(
+ array(
+ 'hook' => $hook,
+ 'args' => array( 'product_id' => $product_id ),
+ 'group' => 'woocommerce-sales',
+ 'status' => \ActionScheduler_Store::STATUS_PENDING,
+ 'per_page' => -1,
+ ),
+ 'ids'
+ );
+
+ return count( $actions );
+ }
+
/**
* @testDox Action Scheduler events are scheduled when sale date meta is written directly via update_post_meta.
*/