Commit 4431cbc0485 for woocommerce

commit 4431cbc0485d073e71c5f13d53bc82524c115fe3
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date:   Thu Aug 27 11:38:29 2026 +0300

    Fix daily scheduled-sales cron reprocessing every completed sale (#67958)

    * Stop the daily cron restarting sales that already ended

    Before 10.5.0 each loop in wc_scheduled_sales() cleared the meta its own query
    matched on, so the sale dates acted as one-shot work queues: a product could
    match at most once and then went inert.

    The scheduled-sales refactor replaced both loop bodies with
    wc_apply_sale_state_for_product(), which touches only `_price` and deliberately
    keeps the sale price and both dates so they stay available for display. Nothing
    drains the queues any more. Every completed sale matches get_starting_sales() on
    every run, gets `_price` written back to the sale price, then matches
    get_ending_sales() in the same run and is reset. Per affected product, per day:
    two saves with their webhooks, lookup-table refresh and variable-parent sync,
    four hook dispatches, and a window in which a shopper can buy at the expired
    sale price.

    Exclude sales whose end date has passed from get_starting_sales(), reading
    `_sale_price_dates_to` exactly the way get_ending_sales() reads it. A completed
    sale is now inert; one whose end was missed is still ended once by the safety
    net and then stays put.

    Keeping the two queries identical is what makes that safe, so both carry a note
    saying so: narrowing one side alone leaves a product in neither queue, and
    broadening one side alone brings the churn back. They do not share a timestamp,
    each reads the clock when it runs, and it is the call order that makes that
    safe rather than the matching text.

    Developer advisory: both cron loops are wrapped in `if ( $product_ids )`, so on
    a store whose only matches were completed sales the two starting hooks stop
    firing rather than firing with a shorter list, and a listener that has treated
    the daily call as a heartbeat is no longer called.

    Refs #66720

    * State the paired sale-query contract on the interface

    get_starting_sales() and get_ending_sales() now depend on each other: whatever
    one excludes the other has to match, or a product reaches neither queue and its
    price is never settled. That constraint was recorded only on
    WC_Product_Data_Store_CPT.

    An extension registering its own store through `woocommerce_data_stores` never
    opens that file, and overriding just get_ending_sales() to scope it to a
    vendor's own products is a shape that happens in practice. Put the obligation
    where an implementer actually reads it, and name both failure directions rather
    than only stranding.

    Docblocks only. No method added, removed, or resignatured, so existing
    implementers continue to satisfy the contract.

    Refs #66720

    * Name every hook the advisory covers

    The advisory listed the two starting hooks. Measured against a completed sale,
    all four stop: because the starting loop no longer moves `_price` to the sale
    price, a product already back at its regular price falls out of
    get_ending_sales() as well, so neither queue claims it and neither pair fires.

    The daily `delete_transient( 'wc_products_onsale' )` and the product
    transient-version bump sit inside the same `if ( $product_ids )` guards and stop
    with them. A/B on one cron run: without the exclusion all four hooks fire, the
    transient is deleted and the version is bumped; with it, none of that happens and
    `_price` is 100 either way.

    Both cache calls still run whenever a sale genuinely starts or ends.

    Refs #66720

    * Put the developer advisory where release notes will read it

    The advisory was in the entry's `Comment:` field. Nothing reads that field back:
    AddCommand writes it, ValidateCommand whitelists the header, and none of the five
    formatters under tools/changelogger mentions it. The prompt that produces it only
    fires when an author omits an entry, so it is a note to maintainers.

    Move the text into the entry body, which is what the formatters emit, so an
    integrator relying on the daily sale hooks finds it in the release notes.

    Refs #66720

    * Drop the end-date fixtures that expire, and say what settling proves

    The two calendar forms stop discriminating in 2034 once `time()` renders as
    '20...', and no clock-derived replacement lasts either: a fixture has to read as
    past for the checks it catches while sorting above a decimal timestamp for the
    query, and those diverge for good. Keeping a scheduled red build to cover a guard
    that does not exist is a bad trade. '999999999' and '0000-00-00' both hold, and
    '0000-00-00' is the one that splits the query's numeric `> 0` from a PHP one.

    The test's own framing also overstated its result. It shows the queue drains, not
    that the price is right: the product ends at the sale price with a past end date,
    and the lookup table lists it on sale while the reconciler charges the regular
    price. That disagreement predates this fix. Say so where the test claims to
    settle something.

    Refs #66720

    * Cut the NOT EXISTS rationale to one sentence

    Four lines of engine benchmarking to justify one SQL construct is more than the
    choice needs in a docblock. Keep the warning, drop the reasoning: the point is
    that the obvious rewrite is not portable, and a reader who wants the numbers can
    find them in review.

    Refs #66720

    * Cut the inline comments back to what the code cannot say

    Most of these restated the line under them: a fixture labelled "open-ended" above
    $open_ended, a "second run must be inert" note above the assertions that check it,
    a description of the meta writes directly below it.

    What is left is the part a reader cannot get from the code: why a fixture carries
    a third price, why add_post_meta() instead of update_post_meta(), and why the
    boundary test asserts a disjunction rather than a specific queue.

    One of them had also gone stale. The precondition note still described two
    calendar fixtures aging out in 2034, and those were removed when the provider was
    trimmed.

    Refs #66720

diff --git a/plugins/woocommerce/changelog/fix-66720-scheduled-sales-churn b/plugins/woocommerce/changelog/fix-66720-scheduled-sales-churn
new file mode 100644
index 00000000000..861f7b01201
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-66720-scheduled-sales-churn
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop the daily scheduled-sales cron reprocessing every completed scheduled sale as both a starting and an ending sale. Developer note: on a store whose only matches were completed sales, all four of wc_before/after_products_starting_sales and wc_before/after_products_ending_sales stop firing rather than firing with a shorter list, and the daily `wc_products_onsale` transient delete and product transient-version bump stop with them. All of these still run whenever a sale genuinely starts or ends.
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
index c2ce1041f92..c5ef13c9efd 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
@@ -1438,12 +1438,22 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 	/**
 	 * Returns an array of IDs of products that have sales starting soon.
 	 *
+	 * Sales that have already ended are excluded, otherwise get_ending_sales() would undo
+	 * them on the same run and the product would qualify again on every run after that.
+	 * The exclusion must keep reading `_sale_price_dates_to` exactly the way
+	 * get_ending_sales() does, or a product ends up in neither queue. See the note there.
+	 *
+	 * Keep the exclusion as NOT EXISTS: the LEFT JOIN ... IS NULL rewrite is faster on MariaDB
+	 * but materially slower on MySQL.
+	 *
 	 * @since 3.0.0
 	 * @return array
 	 */
 	public function get_starting_sales() {
 		global $wpdb;

+		$now = time();
+
 		// phpcs:ignore WordPress.VIP.DirectDatabaseQuery.DirectQuery
 		return $wpdb->get_col(
 			$wpdb->prepare(
@@ -1455,8 +1465,16 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 					AND postmeta_3.meta_key = '_sale_price'
 					AND postmeta.meta_value > 0
 					AND postmeta.meta_value < %s
-					AND postmeta_2.meta_value != postmeta_3.meta_value",
-				time()
+					AND postmeta_2.meta_value != postmeta_3.meta_value
+					AND NOT EXISTS (
+						SELECT 1 FROM {$wpdb->postmeta} as ended
+						WHERE ended.post_id = postmeta.post_id
+							AND ended.meta_key = '_sale_price_dates_to'
+							AND ended.meta_value > 0
+							AND ended.meta_value < %s
+					)",
+				$now,
+				$now
 			)
 		);
 	}
@@ -1464,6 +1482,17 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 	/**
 	 * Returns an array of IDs of products that have sales which are due to end.
 	 *
+	 * Paired with get_starting_sales(), which excludes on this same `_sale_price_dates_to`
+	 * test, so keep the two identical: narrowing this alone strands a product in neither
+	 * queue, broadening it alone brings the daily churn back. `> 0` compares numerically,
+	 * which is what makes '' and '0000-00-00' read as "no end date"; `< %s` compares as
+	 * strings. Do not bind one as %d.
+	 *
+	 * The two do not share a timestamp: each reads the clock when it runs. What makes that
+	 * safe is the order, not the text. wc_scheduled_sales() runs starting first, so this
+	 * query's `now` is the later one, and anything already ended there is still ended here.
+	 * Reversing that order would strand exactly what the pairing is meant to prevent.
+	 *
 	 * @since 3.0.0
 	 * @return array
 	 */
diff --git a/plugins/woocommerce/includes/interfaces/class-wc-product-data-store-interface.php b/plugins/woocommerce/includes/interfaces/class-wc-product-data-store-interface.php
index ed9244ae56a..6d026795421 100644
--- a/plugins/woocommerce/includes/interfaces/class-wc-product-data-store-interface.php
+++ b/plugins/woocommerce/includes/interfaces/class-wc-product-data-store-interface.php
@@ -53,6 +53,10 @@ interface WC_Product_Data_Store_Interface {
 	/**
 	 * Returns an array of IDs of products that have sales starting soon.
 	 *
+	 * Must exclude sales that have already ended, using the same test get_ending_sales()
+	 * matches on. Implement only one of the pair and a product can reach neither queue,
+	 * leaving its price where it is. See get_ending_sales().
+	 *
 	 * @return array
 	 */
 	public function get_starting_sales();
@@ -60,6 +64,10 @@ interface WC_Product_Data_Store_Interface {
 	/**
 	 * Returns an array of IDs of products that have sales which are due to end.
 	 *
+	 * Paired with get_starting_sales(), which excludes what this matches. Overriding one
+	 * without the other strands products when this is narrowed, and restores the daily
+	 * start/end churn when it is broadened.
+	 *
 	 * @return array
 	 */
 	public function get_ending_sales();
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 8d7d1bfbc95..eccca204e85 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -276,6 +276,293 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
 		$this->assertEquals( 100, wc_get_product( $product->get_id() )->get_price() );
 	}

+	/**
+	 * @testdox A completed scheduled sale is not returned by get_starting_sales().
+	 */
+	public function test_get_starting_sales_excludes_completed_sales(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 100 );
+		$product->set_sale_price( 50 );
+		$product->save();
+
+		update_post_meta( $product->get_id(), '_price', 100 );
+		update_post_meta( $product->get_id(), '_sale_price_dates_from', time() - 300 );
+		update_post_meta( $product->get_id(), '_sale_price_dates_to', time() - 100 );
+
+		$data_store = WC_Data_Store::load( 'product' );
+
+		$this->assertNotContains(
+			(string) $product->get_id(),
+			$data_store->get_starting_sales(),
+			'A sale that already ended must never be started by the daily safety net.'
+		);
+		$this->assertNotContains(
+			(string) $product->get_id(),
+			$data_store->get_ending_sales(),
+			'A completed sale already at the regular price has nothing left to end.'
+		);
+	}
+
+	/**
+	 * @testdox get_starting_sales() still returns sales that have started and not yet ended.
+	 */
+	public function test_get_starting_sales_includes_open_and_future_ending_sales(): void {
+		$data_store = WC_Data_Store::load( 'product' );
+
+		$open_ended = WC_Helper_Product::create_simple_product();
+		$open_ended->set_regular_price( 100 );
+		$open_ended->set_sale_price( 50 );
+		$open_ended->save();
+		update_post_meta( $open_ended->get_id(), '_price', 100 );
+		update_post_meta( $open_ended->get_id(), '_sale_price_dates_from', time() - 100 );
+		delete_post_meta( $open_ended->get_id(), '_sale_price_dates_to' );
+
+		$still_running = WC_Helper_Product::create_simple_product();
+		$still_running->set_regular_price( 100 );
+		$still_running->set_sale_price( 50 );
+		$still_running->save();
+		update_post_meta( $still_running->get_id(), '_price', 100 );
+		update_post_meta( $still_running->get_id(), '_sale_price_dates_from', time() - 100 );
+		update_post_meta( $still_running->get_id(), '_sale_price_dates_to', time() + 3600 );
+
+		// A direct writer leaves the row present but empty, which the `> 0` term tolerates.
+		$empty_end_date = WC_Helper_Product::create_simple_product();
+		$empty_end_date->set_regular_price( 100 );
+		$empty_end_date->set_sale_price( 50 );
+		$empty_end_date->save();
+		update_post_meta( $empty_end_date->get_id(), '_price', 100 );
+		update_post_meta( $empty_end_date->get_id(), '_sale_price_dates_from', time() - 100 );
+		update_post_meta( $empty_end_date->get_id(), '_sale_price_dates_to', '' );
+
+		$starting = $data_store->get_starting_sales();
+
+		$this->assertContains( (string) $open_ended->get_id(), $starting, 'An open-ended sale must still start.' );
+		$this->assertContains( (string) $still_running->get_id(), $starting, 'A sale whose end is in the future must still start.' );
+		$this->assertContains( (string) $empty_end_date->get_id(), $starting, 'An empty end-date row means no end date, so the sale must still start.' );
+	}
+
+	/**
+	 * @testdox The new exclusion reads date meta the same way get_ending_sales() does.
+	 */
+	public function test_get_starting_sales_matches_ending_sales_on_non_numeric_dates(): void {
+		// A date string from an importer. Year 9999 sorts above any timestamp this code sees,
+		// so both queries must read it as not-yet-ended.
+		$far_future = '9999-12-31';
+
+		// At the regular price, so only the date can exclude it from starting.
+		$not_started = WC_Helper_Product::create_simple_product();
+		$not_started->set_regular_price( 100 );
+		$not_started->set_sale_price( 50 );
+		$not_started->save();
+		update_post_meta( $not_started->get_id(), '_price', 100 );
+		update_post_meta( $not_started->get_id(), '_sale_price_dates_from', time() - 300 );
+		update_post_meta( $not_started->get_id(), '_sale_price_dates_to', $far_future );
+
+		// At the sale price, so it clears the ending query's price predicate and only the date
+		// can exclude it. Without that the assertion would pass either way.
+		$not_ended = WC_Helper_Product::create_simple_product();
+		$not_ended->set_regular_price( 100 );
+		$not_ended->set_sale_price( 50 );
+		$not_ended->save();
+		update_post_meta( $not_ended->get_id(), '_price', 50 );
+		update_post_meta( $not_ended->get_id(), '_sale_price_dates_from', time() - 300 );
+		update_post_meta( $not_ended->get_id(), '_sale_price_dates_to', $far_future );
+
+		$data_store = WC_Data_Store::load( 'product' );
+
+		$this->assertContains(
+			(string) $not_started->get_id(),
+			$data_store->get_starting_sales(),
+			'A non-numeric end date must not read as ended in the starting query.'
+		);
+		$this->assertNotContains(
+			(string) $not_ended->get_id(),
+			$data_store->get_ending_sales(),
+			'The ending query must read the same value the same way, or the two disagree.'
+		);
+	}
+
+	/**
+	 * End-date values the query still returns, in shapes a PHP-side check reads as ended.
+	 *
+	 * The query returns both, so the consumer has to write the price to settle them. Anything
+	 * deciding "ended" for itself skips that write and re-queues the product forever.
+	 * '0000-00-00' is the one value where the query's numeric `> 0` disagrees with a PHP
+	 * `$v > 0`; '999999999' is a plain past timestamp that still sorts above the current one.
+	 *
+	 * Calendar forms such as '2020-01-01' were dropped: they age out in 2034 once `time()`
+	 * renders as '20...', and no clock-derived replacement survives, since a fixture must read
+	 * as past for the checks it catches yet sort above a decimal timestamp for the query.
+	 *
+	 * @return array<string, array{string}>
+	 */
+	public function provider_end_dates_the_query_still_returns(): array {
+		return array(
+			'short numeric' => array( '999999999' ),
+			'zero date'     => array( '0000-00-00' ),
+		);
+	}
+
+	/**
+	 * @testdox A sale the query still returns is started once and stops being re-queued.
+	 *
+	 * @dataProvider provider_end_dates_the_query_still_returns
+	 *
+	 * @param string $date_to Stored `_sale_price_dates_to` value.
+	 */
+	public function test_wc_scheduled_sales_settles_a_sale_the_query_still_returns( string $date_to ): void {
+		// A full cycle, not just the query: the consumer's price write is what drains the queue.
+		// This shows it drains, not that the price is right. It is not, the product ends at the
+		// sale price with a past end date, which predates this fix.
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 100 );
+		$product->set_sale_price( 50 );
+		$product->save();
+		update_post_meta( $product->get_id(), '_price', 100 );
+		update_post_meta( $product->get_id(), '_sale_price_dates_from', time() - 300 );
+		update_post_meta( $product->get_id(), '_sale_price_dates_to', $date_to );
+
+		$data_store = WC_Data_Store::load( 'product' );
+
+		// Precondition, not a result: the rest only means anything while the query returns it.
+		$this->assertContains(
+			(string) $product->get_id(),
+			$data_store->get_starting_sales(),
+			"get_starting_sales() no longer returns the fixture '{$date_to}'. Either the query "
+				. 'changed, or this value aged out and needs replacing (see the provider docblock).'
+		);
+
+		$started = array();
+		add_action(
+			'wc_before_products_starting_sales',
+			function ( $ids ) use ( &$started ) {
+				$started = array_merge( $started, $ids );
+			}
+		);
+
+		wc_scheduled_sales();
+		$this->assertContains( (string) $product->get_id(), $started, "The first run should start the sale: {$date_to}." );
+
+		$started = array();
+		wc_scheduled_sales();
+		$this->assertNotContains( (string) $product->get_id(), $started, "The product must settle instead of being queued again: {$date_to}." );
+
+		$this->assertNotContains( (string) $product->get_id(), $data_store->get_starting_sales() );
+	}
+
+	/**
+	 * @testdox An end date of exactly now still leaves the product in one of the two queues.
+	 */
+	public function test_an_end_date_of_exactly_now_lands_in_one_queue(): void {
+		// Asserting that some queue claims it, rather than which one, keeps this deterministic
+		// without freezing the clock. Neither queue is the failure, and a `<=` on one side alone
+		// is what produces it, which nothing else here catches.
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 100 );
+		$product->set_sale_price( 50 );
+		$product->save();
+
+		// A third price, so neither query's price predicate can be what excludes it.
+		update_post_meta( $product->get_id(), '_price', 75 );
+		update_post_meta( $product->get_id(), '_sale_price_dates_from', time() - 300 );
+		update_post_meta( $product->get_id(), '_sale_price_dates_to', time() );
+
+		$data_store = WC_Data_Store::load( 'product' );
+		$id         = (string) $product->get_id();
+
+		$starting = in_array( $id, $data_store->get_starting_sales(), true );
+		$ending   = in_array( $id, $data_store->get_ending_sales(), true );
+
+		$this->assertTrue(
+			$starting || $ending,
+			'An end date of exactly now left the product in neither queue, so nothing will ever '
+				. 'settle its price. The two queries have stopped reading the date the same way.'
+		);
+	}
+
+	/**
+	 * @testdox Duplicate end-date rows exclude on the ended one and never duplicate the product.
+	 */
+	public function test_duplicate_end_date_rows_are_handled(): void {
+		// add_post_meta(): direct writers can leave several rows for one key.
+		$expired_and_open = WC_Helper_Product::create_simple_product();
+		$expired_and_open->set_regular_price( 100 );
+		$expired_and_open->set_sale_price( 50 );
+		$expired_and_open->save();
+		update_post_meta( $expired_and_open->get_id(), '_price', 100 );
+		update_post_meta( $expired_and_open->get_id(), '_sale_price_dates_from', time() - 300 );
+		delete_post_meta( $expired_and_open->get_id(), '_sale_price_dates_to' );
+		add_post_meta( $expired_and_open->get_id(), '_sale_price_dates_to', time() - 100 );
+		add_post_meta( $expired_and_open->get_id(), '_sale_price_dates_to', time() + 3600 );
+
+		$both_open = WC_Helper_Product::create_simple_product();
+		$both_open->set_regular_price( 100 );
+		$both_open->set_sale_price( 50 );
+		$both_open->save();
+		update_post_meta( $both_open->get_id(), '_price', 100 );
+		update_post_meta( $both_open->get_id(), '_sale_price_dates_from', time() - 300 );
+		delete_post_meta( $both_open->get_id(), '_sale_price_dates_to' );
+		add_post_meta( $both_open->get_id(), '_sale_price_dates_to', time() + 3600 );
+		add_post_meta( $both_open->get_id(), '_sale_price_dates_to', time() + 7200 );
+
+		$starting = WC_Data_Store::load( 'product' )->get_starting_sales();
+
+		$this->assertNotContains(
+			(string) $expired_and_open->get_id(),
+			$starting,
+			'One ended row is enough to exclude, however many other rows sit beside it.'
+		);
+		$this->assertSame(
+			1,
+			count( array_keys( $starting, (string) $both_open->get_id(), true ) ),
+			'Two open rows must not multiply the product into the result set.'
+		);
+	}
+
+	/**
+	 * @testdox A product left at an expired sale price is repaired once and then goes inert.
+	 */
+	public function test_wc_scheduled_sales_repairs_expired_price_once(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 100 );
+		$product->set_sale_price( 50 );
+		$product->save();
+
+		// A missed end: _price still holds the sale price after the window closed.
+		update_post_meta( $product->get_id(), '_price', 50 );
+		update_post_meta( $product->get_id(), '_sale_price_dates_from', time() - 300 );
+		update_post_meta( $product->get_id(), '_sale_price_dates_to', time() - 100 );
+
+		$started = array();
+		$ended   = array();
+		add_action(
+			'wc_before_products_starting_sales',
+			function ( $ids ) use ( &$started ) {
+				$started = array_merge( $started, $ids );
+			}
+		);
+		add_action(
+			'wc_before_products_ending_sales',
+			function ( $ids ) use ( &$ended ) {
+				$ended = array_merge( $ended, $ids );
+			}
+		);
+
+		wc_scheduled_sales();
+
+		$this->assertNotContains( (string) $product->get_id(), $started, 'An expired sale must not be reported as starting.' );
+		$this->assertContains( (string) $product->get_id(), $ended, 'The safety net should end it once.' );
+		$this->assertEquals( 100, get_post_meta( $product->get_id(), '_price', true ) );
+
+		$started = array();
+		$ended   = array();
+		wc_scheduled_sales();
+
+		$this->assertNotContains( (string) $product->get_id(), $started, 'The churn must not resume on the next run.' );
+		$this->assertNotContains( (string) $product->get_id(), $ended, 'The churn must not resume on the next run.' );
+		$this->assertEquals( 100, get_post_meta( $product->get_id(), '_price', true ) );
+	}
+
 	/**
 	 * @testdox An ended scheduled sale displays the regular price before the AS event runs.
 	 */