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.