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