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.
 	 */