Commit 2fd8ea24b6d for woocommerce

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

    Add the woocommerce_webhook_delivery_priority filter for webhook deliveries (#69881)

    * Add the woocommerce_webhook_delivery_priority filter

    Webhook deliveries are now scheduled through ActionSchedulerUtil with a
    priority from the new filter (default 10, clamped to 0-255) instead of
    WC()->queue(). The 10-minute duplicate check keeps today's semantics.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Test the far-off dedupe branch, document when the filter runs, drop the resolved baseline entry

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Add NumberUtil::normalize_integer() and use it for the webhook delivery priority

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Simplify NumberUtil::normalize_integer() and extend its tests

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Normalize the webhook priority inline and remove normalize_integer()

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Express minutes as a multiple of MINUTES_IN_SECONDS, instead of raw minutes.

    * Read the clock once per webhook queue run

    ---------

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

diff --git a/plugins/woocommerce/changelog/update-wooplug-2435-webhook-delivery-priority b/plugins/woocommerce/changelog/update-wooplug-2435-webhook-delivery-priority
new file mode 100644
index 00000000000..2cea94dba2f
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-wooplug-2435-webhook-delivery-priority
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the woocommerce_webhook_delivery_priority filter to set the Action Scheduler priority of queued webhook deliveries.
diff --git a/plugins/woocommerce/includes/wc-webhook-functions.php b/plugins/woocommerce/includes/wc-webhook-functions.php
index 32d4915e758..a52aee247cf 100644
--- a/plugins/woocommerce/includes/wc-webhook-functions.php
+++ b/plugins/woocommerce/includes/wc-webhook-functions.php
@@ -8,6 +8,8 @@

 defined( 'ABSPATH' ) || exit;

+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
+
 /**
  * Process the web hooks at the end of the request.
  *
@@ -19,6 +21,8 @@ function wc_webhook_execute_queue() {
 		return;
 	}

+	$now = time();
+
 	foreach ( $wc_queued_webhooks as $data ) {
 		// Webhooks are processed in the background by default
 		// so as to avoid delays or failures in delivery from affecting the
@@ -30,11 +34,24 @@ function wc_webhook_execute_queue() {
 				'arg'        => $data['arg'],
 			);

-			$next_scheduled_date = WC()->queue()->get_next( 'woocommerce_deliver_webhook_async', $queue_args, 'woocommerce-webhooks' );
+			$next_scheduled = as_next_scheduled_action( 'woocommerce_deliver_webhook_async', $queue_args, 'woocommerce-webhooks' );

 			// Make webhooks unique - only schedule one webhook every 10 minutes to maintain backward compatibility with WP Cron behaviour seen in WC < 3.5.0.
-			if ( is_null( $next_scheduled_date ) || $next_scheduled_date->getTimestamp() >= ( 600 + gmdate( 'U' ) ) ) {
-				WC()->queue()->add( 'woocommerce_deliver_webhook_async', $queue_args, 'woocommerce-webhooks' );
+			// A pending delivery has a timestamp; an in-progress delivery (true) or none (false) gets a new one.
+			if ( ! is_numeric( $next_scheduled ) || $next_scheduled >= ( 10 * MINUTE_IN_SECONDS + $now ) ) {
+				/**
+				 * Filters the Action Scheduler priority of a queued webhook delivery.
+				 *
+				 * @since 11.4.0
+				 *
+				 * @param int        $priority The delivery priority, expected to be in the range 0-255 (lower numbers take precedence). Default 10.
+				 * @param WC_Webhook $webhook  The webhook being delivered.
+				 * @param mixed      $arg      The argument the webhook fired with, usually the resource ID.
+				 */
+				$priority = apply_filters( 'woocommerce_webhook_delivery_priority', 10, $data['webhook'], $data['arg'] );
+				$priority = is_numeric( $priority ) && is_finite( $priority ) ? (int) max( 0, min( $priority, 255 ) ) : 10;
+
+				ActionSchedulerUtil::schedule_single( $now, 'woocommerce_deliver_webhook_async', $queue_args, 'woocommerce-webhooks', false, $priority );
 			}
 		} else {
 			// Deliver immediately.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index af9fe5fa549..1c158ad6c22 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -35409,12 +35409,6 @@ parameters:
 			count: 1
 			path: includes/wc-user-functions.php

-		-
-			message: '#^Binary operation "\+" between 600 and string results in an error\.$#'
-			identifier: binaryOp.invalid
-			count: 1
-			path: includes/wc-webhook-functions.php
-
 		-
 			message: '#^Call to an undefined method WC_Data_Store\:\:get_webhooks_ids\(\)\.$#'
 			identifier: method.notFound
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/webhooks/functions.php b/plugins/woocommerce/tests/legacy/unit-tests/webhooks/functions.php
index 26a832b9e0e..01f32e0f47d 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/webhooks/functions.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/webhooks/functions.php
@@ -4,6 +4,8 @@
  * @package WooCommerce\Tests\Webhook
  */

+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
+
 /**
  * Class Functions.
  * @package WooCommerce\Tests\Webhook
@@ -17,6 +19,15 @@ class WC_Tests_Webhook_Functions extends WC_Unit_Test_Case {
 	 */
 	public $delivery_counter = array();

+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		$this->reset_legacy_proxy_mocks();
+		ActionSchedulerUtil::reset_cache();
+		parent::tearDown();
+	}
+
 	/**
 	 * Data provider for test_wc_is_webhook_valid_topic.
 	 *
@@ -324,6 +335,171 @@ class WC_Tests_Webhook_Functions extends WC_Unit_Test_Case {
 		$this->assertFalse( wc_load_webhooks( $status, 1 ) );
 	}

+	/**
+	 * An overflowing value lands at 255, the lowest priority, not at 0, the highest.
+	 *
+	 * @testdox Should schedule webhook deliveries at the priority returned by woocommerce_webhook_delivery_priority, clamped to 0-255, defaulting to 10 when unfiltered, non-numeric or non-finite.
+	 * @testWith ["unhooked", 10]
+	 *           [3, 3]
+	 *           ["7", 7]
+	 *           [-5, 0]
+	 *           [300, 255]
+	 *           [9223372036854775808, 255]
+	 *           ["1e309", 10]
+	 *           [null, 10]
+	 *           ["high", 10]
+	 *
+	 * @param mixed $filtered_priority Value returned by the filter, or "unhooked" to leave the filter unhooked.
+	 * @param int   $expected_priority Priority Action Scheduler is expected to store.
+	 */
+	public function test_wc_webhook_execute_queue_schedules_at_filtered_priority( $filtered_priority, $expected_priority ) {
+		$webhook = $this->create_webhook( 'action.wc_test_webhook_priority' );
+		$arg     = 123;
+
+		if ( 'unhooked' !== $filtered_priority ) {
+			add_filter(
+				'woocommerce_webhook_delivery_priority',
+				function ( $priority, $filtered_webhook, $filtered_arg ) use ( $webhook, $arg, $filtered_priority ) {
+					return $webhook->get_id() === $filtered_webhook->get_id() && $arg === $filtered_arg ? $filtered_priority : $priority;
+				},
+				10,
+				3
+			);
+		}
+
+		$action_id = $this->execute_webhook_queue_for( $webhook, $arg );
+
+		$this->assertSame( $expected_priority, ActionScheduler::store()->fetch_action( (string) $action_id )->get_priority() );
+	}
+
+	/**
+	 * @testdox Should keep one pending delivery per webhook and argument, and schedule a new one while a delivery is in progress.
+	 */
+	public function test_wc_webhook_execute_queue_keeps_one_pending_delivery_per_webhook_and_argument() {
+		$webhook = $this->create_webhook( 'action.wc_test_webhook_dedupe' );
+
+		$first = $this->execute_webhook_queue_for( $webhook, 123 );
+		$this->assertSame( $first, $this->execute_webhook_queue_for( $webhook, 123 ), 'A second event for the same resource should reuse the pending delivery' );
+		$this->assertNotSame( $first, $this->execute_webhook_queue_for( $webhook, 456 ), 'A different argument should get its own delivery' );
+
+		ActionScheduler::store()->log_execution( $first );
+		$this->assertNotSame( $first, $this->execute_webhook_queue_for( $webhook, 123 ), 'An in-progress delivery should not block a new one' );
+	}
+
+	/**
+	 * @testdox Should still schedule the delivery, at the default priority, on a loaded Action Scheduler without priority support.
+	 */
+	public function test_wc_webhook_execute_queue_schedules_at_default_priority_on_a_copy_without_priority_support() {
+		$webhook     = $this->create_webhook( 'action.wc_test_webhook_priority_legacy' );
+		$filter_runs = 0;
+		add_filter(
+			'woocommerce_webhook_delivery_priority',
+			function () use ( &$filter_runs ) {
+				++$filter_runs;
+				return 1;
+			}
+		);
+
+		// The util probes priority support by counting the parameters of as_schedule_single_action(); four is a copy before 3.5.0.
+		$this->register_legacy_proxy_class_mocks(
+			array(
+				ReflectionFunction::class => fn() => new class() {
+					/**
+					 * Mirror of ReflectionFunction::getNumberOfParameters().
+					 *
+					 * @return int
+					 */
+					public function getNumberOfParameters(): int { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
+						return 4;
+					}
+				},
+			)
+		);
+		ActionSchedulerUtil::reset_cache();
+
+		$action_id = $this->execute_webhook_queue_for( $webhook, 123 );
+
+		$this->assertSame( 1, $filter_runs, 'The filter should run regardless of the loaded copy' );
+		$this->assertSame( 10, ActionScheduler::store()->fetch_action( (string) $action_id )->get_priority() );
+	}
+
+	/**
+	 * @testdox Should schedule a new delivery when the pending one is more than 10 minutes away.
+	 */
+	public function test_wc_webhook_execute_queue_schedules_a_new_delivery_when_the_pending_one_is_far_off() {
+		$webhook = $this->create_webhook( 'action.wc_test_webhook_far_off' );
+		$arg     = 123;
+		as_schedule_single_action(
+			time() + HOUR_IN_SECONDS,
+			'woocommerce_deliver_webhook_async',
+			array(
+				'webhook_id' => $webhook->get_id(),
+				'arg'        => $arg,
+			),
+			'woocommerce-webhooks'
+		);
+
+		$this->queue_webhook_delivery( $webhook, $arg );
+
+		$this->assertCount( 2, $this->get_pending_delivery_ids( $webhook, $arg ), 'A pending delivery more than 10 minutes away should not be reused' );
+	}
+
+	/**
+	 * Run wc_webhook_execute_queue() for one queued webhook and return the ID of its single pending delivery.
+	 *
+	 * @param WC_Webhook $webhook The webhook to queue.
+	 * @param mixed      $arg     The delivery argument.
+	 * @return int
+	 */
+	private function execute_webhook_queue_for( $webhook, $arg ) {
+		$this->queue_webhook_delivery( $webhook, $arg );
+
+		$action_ids = $this->get_pending_delivery_ids( $webhook, $arg );
+		$this->assertCount( 1, $action_ids, 'Exactly one pending delivery should exist for the webhook and argument' );
+
+		return (int) reset( $action_ids );
+	}
+
+	/**
+	 * Run wc_webhook_execute_queue() for one queued webhook.
+	 *
+	 * @param WC_Webhook $webhook The webhook to queue.
+	 * @param mixed      $arg     The delivery argument.
+	 */
+	private function queue_webhook_delivery( $webhook, $arg ) {
+		global $wc_queued_webhooks;
+		$wc_queued_webhooks = array(
+			array(
+				'webhook' => $webhook,
+				'arg'     => $arg,
+			),
+		);
+		wc_webhook_execute_queue();
+		$wc_queued_webhooks = null;
+	}
+
+	/**
+	 * Get the IDs of the pending deliveries scheduled for a webhook and argument.
+	 *
+	 * @param WC_Webhook $webhook The webhook.
+	 * @param mixed      $arg     The delivery argument.
+	 * @return int[]
+	 */
+	private function get_pending_delivery_ids( $webhook, $arg ) {
+		return as_get_scheduled_actions(
+			array(
+				'hook'   => 'woocommerce_deliver_webhook_async',
+				'args'   => array(
+					'webhook_id' => $webhook->get_id(),
+					'arg'        => $arg,
+				),
+				'group'  => 'woocommerce-webhooks',
+				'status' => ActionScheduler_Store::STATUS_PENDING,
+			),
+			'ids'
+		);
+	}
+
 	/**
 	 * Verify that a webhook that has multiple hooks defined (in WC_Webhook::get_topic_hooks()),
 	 * is only delivered once.