Commit 89c0bccb539 for woocommerce
commit 89c0bccb539157a247a0bf42914234dbe708a2a7
Author: Brandon Kraft <public@brandonkraft.com>
Date: Thu Jun 4 16:17:03 2026 -0500
Fix duplicate processors accumulating in wc_pending_batch_processes (#65450)
* fix: prevent duplicate processors accumulating in pending batch queue
* add: changelog entry for batch processing duplicate fix
* fix: guard batch processor dedup against corrupted option values
* fix: bound batch processor dedup memory when healing bloated option
diff --git a/plugins/woocommerce/changelog/fix-batch-processing-duplicate-enqueue b/plugins/woocommerce/changelog/fix-batch-processing-duplicate-enqueue
new file mode 100644
index 00000000000..a2a4114a86d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-batch-processing-duplicate-enqueue
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix BatchProcessingController::enqueue_processor() appending duplicate entries to the wc_pending_batch_processes option.
diff --git a/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php b/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php
index 52c07de7c40..bbf02af23d6 100644
--- a/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php
+++ b/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php
@@ -97,10 +97,27 @@ class BatchProcessingController {
*/
public function enqueue_processor( string $processor_class_name ): void {
$pending_updates = $this->get_enqueued_processors();
- if ( ! in_array( $processor_class_name, array_keys( $pending_updates ), true ) ) {
- $pending_updates[] = $processor_class_name;
- $this->set_enqueued_processors( $pending_updates );
+
+ // De-duplicate defensively. Historically this method compared the class name against array_keys() rather
+ // than the stored values, so the same processor was appended on every call and bloated the option. Building
+ // the unique list in a single pass heals stores already carrying duplicates on their next enqueue while
+ // keeping only one entry per class name in memory (so the cleanup stays bounded even when the stored list
+ // ballooned to thousands of entries), and skips any non-string values a corrupted option may hold.
+ $deduplicated_updates = array();
+ $seen = array();
+ foreach ( $pending_updates as $value ) {
+ if ( is_string( $value ) && ! isset( $seen[ $value ] ) ) {
+ $seen[ $value ] = true;
+ $deduplicated_updates[] = $value;
+ }
+ }
+ if ( ! in_array( $processor_class_name, $deduplicated_updates, true ) ) {
+ $deduplicated_updates[] = $processor_class_name;
}
+ if ( $deduplicated_updates !== $pending_updates ) {
+ $this->set_enqueued_processors( $deduplicated_updates );
+ }
+
$this->schedule_watchdog_action( false, true );
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/BatchProcessing/BatchProcessingControllerTests.php b/plugins/woocommerce/tests/php/src/Internal/BatchProcessing/BatchProcessingControllerTests.php
index 6d229738105..0b184b190ee 100644
--- a/plugins/woocommerce/tests/php/src/Internal/BatchProcessing/BatchProcessingControllerTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/BatchProcessing/BatchProcessingControllerTests.php
@@ -50,6 +50,119 @@ class BatchProcessingControllerTests extends \WC_Unit_Test_Case {
$this->assertTrue( $this->sut->is_enqueued( get_class( $this->test_process ) ) );
}
+ /**
+ * @testdox Enqueuing the same processor repeatedly keeps a single entry.
+ */
+ public function test_enqueue_processor_is_idempotent(): void {
+ $processor = get_class( $this->test_process );
+
+ $this->sut->enqueue_processor( $processor );
+ $this->sut->enqueue_processor( $processor );
+ $this->sut->enqueue_processor( $processor );
+
+ $enqueued = $this->sut->get_enqueued_processors();
+ $this->assertCount( 1, $enqueued, 'Repeated enqueues of the same processor must not create duplicates.' );
+ $this->assertContains( $processor, $enqueued, 'The enqueued processor should still be present.' );
+ $this->assertCount( 1, get_option( BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME ), 'The persisted option must not contain duplicates.' );
+ }
+
+ /**
+ * @testdox Enqueuing collapses a pre-existing list bloated with duplicates and persists the cleanup.
+ */
+ public function test_enqueue_processor_collapses_preexisting_duplicates(): void {
+ $processor = get_class( $this->test_process );
+
+ // Simulate an option bloated by the historical duplicate bug.
+ update_option(
+ BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME,
+ array_fill( 0, 5, $processor ),
+ false
+ );
+
+ $this->sut->enqueue_processor( $processor );
+
+ $this->assertCount( 1, $this->sut->get_enqueued_processors(), 'Existing duplicates should collapse to a single entry.' );
+ $persisted = get_option( BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME );
+ $this->assertCount( 1, $persisted, 'The de-duplicated list should be persisted.' );
+ $this->assertContains( $processor, $persisted, 'The persisted list should still contain the processor.' );
+ }
+
+ /**
+ * @testdox Enqueuing a processor collapses duplicates of other processors without dropping them.
+ */
+ public function test_enqueue_processor_collapses_duplicates_without_dropping_others(): void {
+ update_option(
+ BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME,
+ array( 'Processor\\A', 'Processor\\A', 'Processor\\A', 'Processor\\B' ),
+ false
+ );
+
+ $this->sut->enqueue_processor( 'Processor\\C' );
+
+ $this->assertSame(
+ array( 'Processor\\A', 'Processor\\B', 'Processor\\C' ),
+ $this->sut->get_enqueued_processors(),
+ 'Collapsing duplicates must preserve other processors and append the new one, in order.'
+ );
+ }
+
+ /**
+ * @testdox Re-enqueuing an already-present processor on a clean list does not rewrite the option.
+ */
+ public function test_enqueue_processor_skips_write_when_unchanged(): void {
+ $processor = get_class( $this->test_process );
+ $this->sut->enqueue_processor( $processor );
+
+ $writes = 0;
+ add_filter(
+ 'pre_update_option_' . BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME,
+ function ( $value ) use ( &$writes ) {
+ ++$writes;
+ return $value;
+ }
+ );
+
+ $this->sut->enqueue_processor( $processor );
+
+ $this->assertSame( 0, $writes, 'A no-op enqueue must not trigger an option write.' );
+ }
+
+ /**
+ * @testdox Enqueuing strips non-string values from a corrupted option without fataling.
+ */
+ public function test_enqueue_processor_strips_non_string_values(): void {
+ $processor = get_class( $this->test_process );
+
+ // A corrupted option containing non-string values would otherwise make array_unique() fatal.
+ update_option(
+ BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME,
+ array( $processor, new \stdClass(), array( 'corrupt' ), 12345 ),
+ false
+ );
+
+ $this->sut->enqueue_processor( $processor );
+
+ $this->assertSame( array( $processor ), $this->sut->get_enqueued_processors(), 'Non-string values must be stripped, leaving only valid processor names.' );
+ }
+
+ /**
+ * @testdox Enqueuing collapses a heavily bloated list of thousands of duplicates to a single entry.
+ */
+ public function test_enqueue_processor_collapses_heavily_bloated_list(): void {
+ $processor = get_class( $this->test_process );
+
+ // Mirror the reported production case (thousands of identical entries).
+ update_option(
+ BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME,
+ array_fill( 0, 3000, $processor ),
+ false
+ );
+
+ $this->sut->enqueue_processor( $processor );
+
+ $this->assertSame( array( $processor ), $this->sut->get_enqueued_processors(), 'A heavily bloated list must collapse to a single entry.' );
+ }
+
/**
* @testdox 'remove_processor' dequeues and unschedules a processor, but the watchdog is kept alive if more processors are still enqueued.
*/