Commit c838398ea4b for woocommerce
commit c838398ea4b327c9688f60d0ecb26937b8087584
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date: Mon Oct 5 13:46:11 2026 +0200
Fix upgrade inbox action accumulation during execution (#68558)
Fix upgrade inbox action accumulation
- Detect pending and running actions with the native Action Scheduler helper when available
- Preserve custom queue searches and existing queue contracts
- Cover repeated upgrade events, async actions, and replacement queues with regression tests
diff --git a/plugins/woocommerce/changelog/fix-upgrade-inbox-action-pileup b/plugins/woocommerce/changelog/fix-upgrade-inbox-action-pileup
new file mode 100644
index 00000000000..faa1c9e07fa
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-upgrade-inbox-action-pileup
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent duplicate remote inbox update actions from accumulating while an existing action is running or waiting asynchronously.
diff --git a/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngine.php b/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngine.php
index 8b724c1140e..6ecca0a1b4b 100644
--- a/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngine.php
+++ b/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngine.php
@@ -52,19 +52,37 @@ class RemoteInboxNotificationsEngine extends RemoteSpecsEngine {
add_action(
'woocommerce_updated',
function () {
- $next_hook = WC()->queue()->get_next(
+ $queue = WC()->queue();
+ // Custom queues, including subclasses, must retain their search overrides.
+ if ( \WC_Action_Queue::class === get_class( $queue ) && function_exists( 'as_has_scheduled_action' ) ) {
+ if ( as_has_scheduled_action( 'woocommerce_run_on_woocommerce_admin_updated', array(), 'woocommerce-remote-inbox-engine' ) ) {
+ return;
+ }
+ } else {
+ // A date lookup cannot represent running actions or pending async actions.
+ foreach ( array( \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ) as $status ) {
+ $actions = $queue->search(
+ array(
+ 'hook' => 'woocommerce_run_on_woocommerce_admin_updated',
+ 'args' => array(),
+ 'group' => 'woocommerce-remote-inbox-engine',
+ 'status' => $status,
+ 'per_page' => 1,
+ ),
+ 'ids'
+ );
+ if ( ! empty( $actions ) ) {
+ return;
+ }
+ }
+ }
+
+ $queue->schedule_single(
+ time(),
'woocommerce_run_on_woocommerce_admin_updated',
array(),
'woocommerce-remote-inbox-engine'
);
- if ( null === $next_hook ) {
- WC()->queue()->schedule_single(
- time(),
- 'woocommerce_run_on_woocommerce_admin_updated',
- array(),
- 'woocommerce-remote-inbox-engine'
- );
- }
}
);
diff --git a/plugins/woocommerce/tests/php/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngineTest.php b/plugins/woocommerce/tests/php/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngineTest.php
new file mode 100644
index 00000000000..6ba05e34d4d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngineTest.php
@@ -0,0 +1,272 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\RemoteInboxNotifications;
+
+use ActionScheduler;
+use ActionScheduler_Store;
+use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsEngine;
+use WC_Action_Queue;
+use WC_Queue;
+use WC_Queue_Interface;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for upgrade-triggered remote inbox scheduling.
+ *
+ * @covers \Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsEngine
+ */
+class RemoteInboxNotificationsEngineTest extends WC_Unit_Test_Case {
+
+ /**
+ * The upgrade action hook.
+ */
+ private const HOOK = 'woocommerce_run_on_woocommerce_admin_updated';
+
+ /**
+ * The engine's action group.
+ */
+ private const GROUP = 'woocommerce-remote-inbox-engine';
+
+ /**
+ * Initialize only the engine's update listener. The parent restores hooks and database fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ // An upgrade during test bootstrap can leave work outside the test transaction.
+ $store = ActionScheduler::store();
+ $action_ids = $store->query_actions(
+ array(
+ 'hook' => self::HOOK,
+ 'group' => self::GROUP,
+ 'per_page' => -1,
+ )
+ );
+ foreach ( $action_ids as $action_id ) {
+ $store->delete_action( $action_id );
+ }
+ remove_all_actions( 'woocommerce_updated' );
+ RemoteInboxNotificationsEngine::init();
+ }
+
+ /**
+ * @testdox Repeated updates do not add pending copies while an upgrade action is running.
+ * @dataProvider running_action_provider
+ *
+ * @param int $pending_count Existing pending copies alongside the running action.
+ */
+ public function test_running_action_prevents_accumulation( int $pending_count ): void {
+ $running_id = as_schedule_single_action( time() - 60, self::HOOK, array(), self::GROUP );
+ ActionScheduler::store()->log_execution( $running_id );
+ for ( $i = 0; $i < $pending_count; $i++ ) {
+ as_schedule_single_action( time() - 30, self::HOOK, array(), self::GROUP );
+ }
+ for ( $i = 0; $i < 10; $i++ ) {
+ as_schedule_single_action( time() - 120, 'unrelated_upgrade_backlog', array( $i ), self::GROUP );
+ }
+
+ $this->assertSame( ActionScheduler_Store::STATUS_RUNNING, ActionScheduler::store()->get_status( $running_id ), 'The stored action must be running.' );
+ $this->assertTrue( as_next_scheduled_action( self::HOOK, array(), self::GROUP ), 'A running action masks pending dates in the Action Scheduler lookup.' );
+ $this->assertNull( WC()->queue()->get_next( self::HOOK, array(), self::GROUP ), 'The date-based queue lookup cannot represent running work.' );
+
+ $this->fire_updates();
+
+ $this->assertCount( $pending_count, $this->pending_actions(), 'Repeated updates must not grow the pending backlog while an action runs.' );
+ }
+
+ /**
+ * Existing backlog sizes for a running action.
+ *
+ * @return array<string, array{int}>
+ */
+ public function running_action_provider(): array {
+ return array(
+ 'running alone' => array( 0 ),
+ 'running with backlog' => array( 3 ),
+ );
+ }
+
+ /**
+ * @testdox A pending async upgrade action prevents additional scheduled copies.
+ */
+ public function test_pending_async_action_prevents_accumulation(): void {
+ $action_id = as_enqueue_async_action( self::HOOK, array(), self::GROUP );
+ $this->assertInstanceOf( \ActionScheduler_NullSchedule::class, ActionScheduler::store()->fetch_action( $action_id )->get_schedule() );
+ $this->assertTrue( as_next_scheduled_action( self::HOOK, array(), self::GROUP ) );
+ $this->assertNull( WC()->queue()->get_next( self::HOOK, array(), self::GROUP ) );
+
+ $this->fire_updates();
+
+ $this->assertSame( array( $action_id ), $this->pending_actions(), 'Pending async work must prevent another copy even without a scheduled date.' );
+ }
+
+ /**
+ * @testdox Repeated updates schedule one action when there is no active upgrade work.
+ * @dataProvider finished_action_provider
+ *
+ * @param string|null $status Existing finished action status, or null for an empty queue.
+ */
+ public function test_schedules_after_finished_work( ?string $status ): void {
+ if ( null !== $status ) {
+ $action_id = as_schedule_single_action( time() - 60, self::HOOK, array(), self::GROUP );
+ $store = ActionScheduler::store();
+ if ( ActionScheduler_Store::STATUS_COMPLETE === $status ) {
+ $store->mark_complete( $action_id );
+ } elseif ( ActionScheduler_Store::STATUS_FAILED === $status ) {
+ $store->mark_failure( $action_id );
+ } else {
+ $store->cancel_action( $action_id );
+ }
+ }
+
+ $this->fire_updates();
+
+ $this->assertCount( 1, $this->pending_actions(), 'There should be exactly one fresh action after repeated updates.' );
+ }
+
+ /**
+ * Inactive queue states that allow a fresh action.
+ *
+ * @return array<string, array{string|null}>
+ */
+ public function finished_action_provider(): array {
+ return array(
+ 'empty' => array( null ),
+ 'completed' => array( ActionScheduler_Store::STATUS_COMPLETE ),
+ 'failed' => array( ActionScheduler_Store::STATUS_FAILED ),
+ 'canceled' => array( ActionScheduler_Store::STATUS_CANCELED ),
+ );
+ }
+
+ /**
+ * @testdox An ordinary pending action prevents duplicate upgrade work.
+ */
+ public function test_pending_scheduled_action_prevents_accumulation(): void {
+ $action_id = as_schedule_single_action( time() + 60, self::HOOK, array(), self::GROUP );
+
+ $this->fire_updates();
+
+ $this->assertSame( array( $action_id ), $this->pending_actions(), 'The existing pending action should be reused.' );
+ }
+
+ /**
+ * @testdox Pending and running work with a different hook, arguments or group does not suppress upgrade work.
+ * @dataProvider unrelated_action_provider
+ *
+ * @param string $hook The unrelated action's hook.
+ * @param array $args The unrelated action's arguments.
+ * @param string $group The unrelated action's group.
+ */
+ public function test_unrelated_work_does_not_suppress_scheduling( string $hook, array $args, string $group ): void {
+ $action_id = as_schedule_single_action( time() - 60, $hook, $args, $group );
+ ActionScheduler::store()->log_execution( $action_id );
+ as_schedule_single_action( time() - 30, $hook, $args, $group );
+
+ $this->fire_updates();
+
+ $this->assertCount( 1, $this->pending_actions(), 'Only work matching the exact hook, arguments and group should suppress scheduling.' );
+ }
+
+ /**
+ * Actions differing in exactly one part of the engine's scope.
+ *
+ * @return array<string, array{string, array, string}>
+ */
+ public function unrelated_action_provider(): array {
+ return array(
+ 'different hook' => array( 'unrelated_upgrade_action', array(), self::GROUP ),
+ 'different args' => array( self::HOOK, array( 'unrelated' ), self::GROUP ),
+ 'different group' => array( self::HOOK, array(), 'unrelated-upgrade-group' ),
+ );
+ }
+
+ /**
+ * @testdox Replacement queues control scheduling through the existing queue interface.
+ * @dataProvider replacement_queue_provider
+ *
+ * @param string $queue_class Queue interface or class to replace.
+ * @param string|null $active_status Existing active work, or null for an empty queue.
+ */
+ public function test_replacement_queue_controls_scheduling( string $queue_class, ?string $active_status ): void {
+ $queue = $this->createMock( $queue_class );
+ $should_create = null === $active_status;
+ if ( $should_create ) {
+ as_schedule_single_action( time() + 60, self::HOOK, array(), self::GROUP );
+ }
+ $this->assertSame( $should_create, as_has_scheduled_action( self::HOOK, array(), self::GROUP ), 'Action Scheduler must disagree with the replacement queue so bypassing its search would fail this test.' );
+
+ $queue->expects( $this->never() )->method( 'get_next' );
+ $queue->method( 'search' )->willReturnCallback(
+ function ( array $args, string $return_format ) use ( &$active_status ): array {
+ $this->assertContains( $args['status'], array( ActionScheduler_Store::STATUS_PENDING, ActionScheduler_Store::STATUS_RUNNING ), 'Replacement queues receive a scalar status.' );
+ $this->assertSame( self::HOOK, $args['hook'] );
+ $this->assertSame( array(), $args['args'] );
+ $this->assertSame( self::GROUP, $args['group'] );
+ $this->assertSame( 1, $args['per_page'] );
+ $this->assertSame( 'ids', $return_format );
+ return $active_status === $args['status'] ? array( 123 ) : array();
+ }
+ );
+ $queue->expects( $should_create ? $this->once() : $this->never() )
+ ->method( 'schedule_single' )
+ ->with( $this->isType( 'int' ), self::HOOK, array(), self::GROUP )
+ ->willReturnCallback(
+ function () use ( &$active_status ): int {
+ $active_status = ActionScheduler_Store::STATUS_PENDING;
+ return 123;
+ }
+ );
+ $original_queue = WC()->queue();
+ $instance = new \ReflectionProperty( WC_Queue::class, 'instance' );
+ $instance->setAccessible( true );
+ $instance->setValue( null, $queue );
+ try {
+ $this->fire_updates();
+ } finally {
+ $instance->setValue( null, $original_queue );
+ }
+ }
+
+ /**
+ * Active states exposed by a replacement queue.
+ *
+ * @return array<string, array{string, string|null}>
+ */
+ public function replacement_queue_provider(): array {
+ $cases = array();
+ foreach ( array( WC_Queue_Interface::class, WC_Action_Queue::class ) as $queue_class ) {
+ $cases[ $queue_class . ' empty' ] = array( $queue_class, null );
+ $cases[ $queue_class . ' pending' ] = array( $queue_class, ActionScheduler_Store::STATUS_PENDING );
+ $cases[ $queue_class . ' running' ] = array( $queue_class, ActionScheduler_Store::STATUS_RUNNING );
+ }
+ return $cases;
+ }
+
+ /**
+ * Fire repeated upgrade events while the queue is not being drained.
+ */
+ private function fire_updates(): void {
+ for ( $i = 0; $i < 10; $i++ ) {
+ do_action( 'woocommerce_updated' );
+ }
+ }
+
+ /**
+ * Get all pending upgrade actions in the engine's exact scope.
+ *
+ * @return array<int>
+ */
+ private function pending_actions(): array {
+ $action_ids = as_get_scheduled_actions(
+ array(
+ 'hook' => self::HOOK,
+ 'args' => array(),
+ 'group' => self::GROUP,
+ 'status' => ActionScheduler_Store::STATUS_PENDING,
+ 'per_page' => -1,
+ ),
+ 'ids'
+ );
+ return array_map( 'intval', $action_ids );
+ }
+}