Commit 3e5b0974ad6 for woocommerce

commit 3e5b0974ad6848d3fa0d8ecba5068790869fa051
Author: Brandon Kraft <public@brandonkraft.com>
Date:   Mon Jul 13 04:48:46 2026 -0500

    Fix concurrent mutations dropping or resurrecting batch processors (#65877)

diff --git a/plugins/woocommerce/changelog/65452-fix-batch-processing-concurrency b/plugins/woocommerce/changelog/65452-fix-batch-processing-concurrency
new file mode 100644
index 00000000000..489880072fb
--- /dev/null
+++ b/plugins/woocommerce/changelog/65452-fix-batch-processing-concurrency
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Serialize mutations of the wc_pending_batch_processes option in BatchProcessingController with a short-lived database lock and a fresh re-read, so concurrent enqueue/dequeue/remove requests no longer clobber each other and silently drop or resurrect batch processors.
diff --git a/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php b/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php
index bbf02af23d6..c09e608f52f 100644
--- a/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php
+++ b/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php
@@ -51,6 +51,42 @@ class BatchProcessingController {
 	 */
 	const FAILING_PROCESS_MAX_ATTEMPTS_DEFAULT = 5;

+	/**
+	 * Option name used as a self-expiring mutex around mutations of the enqueued-processors option.
+	 *
+	 * The row holds the lock's release time (a microtime float); its mere existence is the lock. The lock lives in
+	 * the options table (rather than a MySQL named lock) so it is naturally scoped to this install's wp_options and
+	 * routes to the primary database like any other write, avoiding the replica-routing and server-global-namespace
+	 * pitfalls of GET_LOCK. Modeled on WC_Install::create_lock().
+	 *
+	 * @since 11.0.0
+	 */
+	const ENQUEUED_PROCESSORS_LOCK_OPTION = 'wc_pending_batch_processes_lock';
+
+	/**
+	 * How long (in seconds, fractional) a held enqueued-processors lock stays valid before it is considered stale
+	 * and can be taken over.
+	 *
+	 * Kept short deliberately: enqueue_processor() runs on the 'shutdown' hook of nearly every request under
+	 * continuous HPOS background sync, and the guarded critical section is just a couple of option queries. The
+	 * value must exceed the worst-case hold time (a normal holder releases in milliseconds), and also bounds how
+	 * long a contender waits before stealing a lock left behind by a crashed request.
+	 *
+	 * @since 11.0.0
+	 */
+	const ENQUEUED_PROCESSORS_LOCK_TTL = 1.0;
+
+	/**
+	 * Number of times to attempt to acquire the enqueued-processors lock before giving up and proceeding without it.
+	 *
+	 * The delay between attempts is ENQUEUED_PROCESSORS_LOCK_TTL / ENQUEUED_PROCESSORS_LOCK_ATTEMPTS, so the total
+	 * acquisition window is one TTL — long enough for a normal holder to finish and release, after which a stale
+	 * lock becomes stealable.
+	 *
+	 * @since 11.0.0
+	 */
+	const ENQUEUED_PROCESSORS_LOCK_ATTEMPTS = 20;
+
 	/**
 	 * Instance of WC_Logger class.
 	 *
@@ -96,29 +132,233 @@ class BatchProcessingController {
 	 * @param string $processor_class_name Fully qualified class name of the processor, must implement `BatchProcessorInterface`.
 	 */
 	public function enqueue_processor( string $processor_class_name ): void {
-		$pending_updates = $this->get_enqueued_processors();
-
-		// 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;
+		/*
+		 * Fast path: if the processor is already enqueued and the stored list is clean, there is nothing to write.
+		 * This avoids taking the lock and re-reading the option on the hot path, which matters because
+		 * DataSynchronizer::handle_continuous_background_sync() calls this on the 'shutdown' hook of every request
+		 * when HPOS background sync runs in continuous mode. The check uses the (possibly request-cached) value;
+		 * the authoritative re-read happens inside the critical section below only when an update is actually needed.
+		 */
+		if ( $this->enqueued_list_needs_update( $this->get_enqueued_processors(), $processor_class_name ) ) {
+			$this->mutate_enqueued_processors(
+				function ( array $pending_updates ) use ( $processor_class_name ): array {
+					$deduplicated_updates = $this->sanitize_processor_list( $pending_updates );
+					if ( ! in_array( $processor_class_name, $deduplicated_updates, true ) ) {
+						$deduplicated_updates[] = $processor_class_name;
+					}
+					return $deduplicated_updates;
+				}
+			);
+		}
+
+		$this->schedule_watchdog_action( false, true );
+	}
+
+	/**
+	 * Reduce a processor list to unique, non-empty class-name strings, preserving order.
+	 *
+	 * The enqueued-processors option is a plain serialized array, so a corrupted option (or the historical
+	 * duplicate-accumulation bug) can leave it holding duplicates, empty strings, or non-string values. Sanitizing
+	 * before any comparison keeps the mutators safe and heals the stored list on the next write: in particular
+	 * array_diff() string-casts its operands and would fatal on an object entry in PHP 8, so removals run on the
+	 * sanitized list. Empty strings are dropped too: they are never a valid class name, and left in place would be
+	 * scheduled and then fatal in get_processor_instance( '' ), with the watchdog rescheduling them indefinitely.
+	 *
+	 * @since 11.0.0
+	 *
+	 * @param array $processors Raw processor list as read from the option.
+	 * @return array Sanitized list of unique class-name strings.
+	 */
+	private function sanitize_processor_list( array $processors ): array {
+		$sanitized = array();
+		$seen      = array();
+		foreach ( $processors as $value ) {
+			if ( is_string( $value ) && '' !== $value && ! isset( $seen[ $value ] ) ) {
+				$seen[ $value ] = true;
+				$sanitized[]    = $value;
+			}
+		}
+		return $sanitized;
+	}
+
+	/**
+	 * Determine whether the stored enqueued-processors list needs to be rewritten to include a given processor
+	 * or to heal pre-existing corruption (duplicates or non-string entries).
+	 *
+	 * @since 11.0.0
+	 *
+	 * @param array  $processors           Current list of enqueued processors.
+	 * @param string $processor_class_name Fully qualified class name of the processor to ensure is enqueued.
+	 *
+	 * @return bool True if the list should be rewritten, false if it is already clean and contains the processor.
+	 */
+	private function enqueued_list_needs_update( array $processors, string $processor_class_name ): bool {
+		$seen           = array();
+		$contains_class = false;
+		foreach ( $processors as $value ) {
+			if ( ! is_string( $value ) || isset( $seen[ $value ] ) ) {
+				// Non-string entry or duplicate: the list needs healing.
+				return true;
+			}
+			$seen[ $value ] = true;
+			if ( $value === $processor_class_name ) {
+				$contains_class = true;
 			}
 		}
-		if ( ! in_array( $processor_class_name, $deduplicated_updates, true ) ) {
-			$deduplicated_updates[] = $processor_class_name;
+
+		return ! $contains_class;
+	}
+
+	/**
+	 * Run a read-modify-write of the enqueued-processors option inside a short-lived critical section.
+	 *
+	 * The list is stored in a single option and mutated by several code paths (including the per-request
+	 * 'shutdown' enqueue from continuous HPOS background sync), so an unguarded read-modify-write can lose
+	 * updates under concurrency: two requests read the same list, each writes its own version, and the later
+	 * write silently drops the other's change. This serializes those mutations with a self-expiring options-table
+	 * mutex and re-reads the freshest persisted value inside the lock so the mutator always operates on current
+	 * state.
+	 *
+	 * The lock is best-effort: if it cannot be acquired within ENQUEUED_PROCESSORS_LOCK_ATTEMPTS tries the mutation
+	 * still proceeds, which is no worse than the previous lock-free behavior. The fresh re-read inside the section
+	 * narrows the race window even when the lock provides no real exclusion, and the watchdog reconciles any
+	 * residual divergence on its next run.
+	 *
+	 * @since 11.0.0
+	 *
+	 * @param callable $mutator Receives the current list (array of class-name strings) and returns the new list.
+	 *
+	 * @return array The list as persisted (the mutator's return value).
+	 */
+	private function mutate_enqueued_processors( callable $mutator ): array {
+		$lock_token = $this->acquire_enqueued_processors_lock();
+		try {
+			/*
+			 * Drop any request-cached copy so the read below reflects writes committed by concurrent requests
+			 * that landed after this request first read the option. Both the per-option entry AND the shared
+			 * 'notoptions' entry must be cleared: when the option does not yet exist (first enqueue, or after a
+			 * corrupted option was deleted), get_option() short-circuits on a stale 'notoptions' hit and would
+			 * otherwise return the default empty list even though a concurrent request just created the row.
+			 */
+			wp_cache_delete( self::ENQUEUED_PROCESSORS_OPTION_NAME, 'options' );
+			wp_cache_delete( 'notoptions', 'options' );
+			$current = $this->get_enqueued_processors();
+			$updated = $mutator( $current );
+			if ( $updated !== $current ) {
+				$this->set_enqueued_processors( $updated );
+			}
+			return $updated;
+		} finally {
+			if ( null !== $lock_token ) {
+				$this->release_enqueued_processors_lock( $lock_token );
+			}
 		}
-		if ( $deduplicated_updates !== $pending_updates ) {
-			$this->set_enqueued_processors( $deduplicated_updates );
+	}
+
+	/**
+	 * Acquire the enqueued-processors lock by claiming a row in the options table.
+	 *
+	 * The claim is an atomic INSERT (which fails if the row already exists, making it a mutex); if the row exists
+	 * but its stored release time has already passed, a conditional UPDATE takes the stale lock over. Failure to
+	 * claim is retried up to ENQUEUED_PROCESSORS_LOCK_ATTEMPTS times with a short delay before the caller falls
+	 * back to its best-effort unguarded path. Modeled on WC_Install::create_lock().
+	 *
+	 * Release times are stored as fixed-width, zero-padded-fraction strings (`number_format( ..., 6 )`) so the
+	 * database compares and matches them as plain strings without a numeric cast; for the current epoch the
+	 * integer part is a stable ten digits, so lexical order matches chronological order.
+	 *
+	 * @since 11.0.0
+	 *
+	 * @return string|null The stored release time when the lock was acquired (which must be passed to
+	 *                     release_enqueued_processors_lock()), or null when it could not be acquired.
+	 */
+	private function acquire_enqueued_processors_lock(): ?string {
+		global $wpdb;
+
+		$attempts    = max( 1, self::ENQUEUED_PROCESSORS_LOCK_ATTEMPTS );
+		$delay_micro = (int) ( ( self::ENQUEUED_PROCESSORS_LOCK_TTL / $attempts ) * 1_000_000 );
+
+		// A contended INSERT fails on the duplicate key by design, so silence wpdb error reporting to keep the
+		// expected-failure path from spamming the log in debug mode; restore the prior setting when done.
+		$suppress = $wpdb->suppress_errors( true );
+		try {
+			for ( $attempt = 0; $attempt < $attempts; $attempt++ ) {
+				$time   = microtime( true );
+				$now    = number_format( $time, 6, '.', '' );
+				$expiry = number_format( $time + self::ENQUEUED_PROCESSORS_LOCK_TTL, 6, '.', '' );
+
+				// The unique option_name key makes this INSERT a mutex: it fails when the lock row already exists.
+				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+				$acquired = $wpdb->insert(
+					$wpdb->options,
+					array(
+						'option_name'  => self::ENQUEUED_PROCESSORS_LOCK_OPTION,
+						'option_value' => $expiry,
+						'autoload'     => 'no',
+					),
+					array( '%s', '%s', '%s' )
+				);
+
+				if ( ! $acquired ) {
+					/*
+					 * The lock row exists; take it over only if its stored release time is already in the past.
+					 * $wpdb->update() cannot express the "<" comparison, so this conditional takeover uses raw SQL.
+					 * The table is a trusted internal identifier and every value is bound through a placeholder.
+					 */
+					$takeover = $wpdb->prepare(
+						"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value < %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+						$expiry,
+						self::ENQUEUED_PROCESSORS_LOCK_OPTION,
+						$now
+					);
+					if ( is_string( $takeover ) ) {
+						// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
+						$acquired = $wpdb->query( $takeover );
+					}
+				}
+
+				if ( $acquired ) {
+					return $expiry;
+				}
+
+				if ( $attempt < $attempts - 1 && $delay_micro > 0 ) {
+					usleep( $delay_micro );
+				}
+			}
+
+			return null;
+		} finally {
+			$wpdb->suppress_errors( $suppress );
 		}
+	}

-		$this->schedule_watchdog_action( false, true );
+	/**
+	 * Release the enqueued-processors lock by deleting the options-table row we own.
+	 *
+	 * The delete is conditional on the stored release time still matching the one written when the lock was
+	 * acquired, so a lock that expired and was taken over by another request is never released out from under it.
+	 *
+	 * @since 11.0.0
+	 *
+	 * @param string $expiry The release time returned by acquire_enqueued_processors_lock().
+	 */
+	private function release_enqueued_processors_lock( string $expiry ): void {
+		global $wpdb;
+
+		$suppress = $wpdb->suppress_errors( true );
+		try {
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+			$wpdb->delete(
+				$wpdb->options,
+				array(
+					'option_name'  => self::ENQUEUED_PROCESSORS_LOCK_OPTION,
+					'option_value' => $expiry,
+				),
+				array( '%s', '%s' )
+			);
+		} finally {
+			$wpdb->suppress_errors( $suppress );
+		}
 	}

 	/**
@@ -156,7 +396,7 @@ class BatchProcessingController {
 	 * (because they have just been enqueued, or because the processing for a batch failed).
 	 */
 	private function handle_watchdog_action(): void {
-		$pending_processes = $this->get_enqueued_processors();
+		$pending_processes = $this->sanitize_processor_list( $this->get_enqueued_processors() );
 		if ( empty( $pending_processes ) ) {
 			return;
 		}
@@ -385,11 +625,21 @@ class BatchProcessingController {
 	 * @param string $processor_class_name Full processor class name.
 	 */
 	private function dequeue_processor( string $processor_class_name ): void {
-		$pending_processes = $this->get_enqueued_processors();
-		if ( in_array( $processor_class_name, $pending_processes, true ) ) {
+		// Always resolve membership authoritatively inside the lock (no unguarded fast-path read): this runs when a
+		// batch finishes, not on a per-request hot path, so correctness is preferred over skipping the lock.
+		$removed = false;
+		$this->mutate_enqueued_processors(
+			function ( array $pending_processes ) use ( $processor_class_name, &$removed ): array {
+				if ( ! in_array( $processor_class_name, $pending_processes, true ) ) {
+					return $pending_processes;
+				}
+				$removed = true;
+				return array_values( array_diff( $this->sanitize_processor_list( $pending_processes ), array( $processor_class_name ) ) );
+			}
+		);
+
+		if ( $removed ) {
 			$this->clear_processor_state( $processor_class_name );
-			$pending_processes = array_diff( $pending_processes, array( $processor_class_name ) );
-			$this->set_enqueued_processors( $pending_processes );
 		}
 	}

@@ -420,19 +670,51 @@ class BatchProcessingController {
 	 * @return bool True if the processor has been dequeued, false if the processor wasn't enqueued (so nothing has been done).
 	 */
 	public function remove_processor( string $processor_class_name ): bool {
-		$enqueued_processors = $this->get_enqueued_processors();
-		if ( ! in_array( $processor_class_name, $enqueued_processors, true ) ) {
+		// Resolve membership authoritatively inside the lock (no unguarded fast-path read). remove_processor() is
+		// not a per-request hot path — the continuous-sync 'shutdown' handler enqueues rather than removes — so it
+		// always takes the lock and re-reads the freshest list rather than acting on a possibly-stale cached copy.
+		$was_enqueued         = false;
+		$remaining_processors = $this->mutate_enqueued_processors(
+			function ( array $enqueued_processors ) use ( $processor_class_name, &$was_enqueued ): array {
+				if ( ! in_array( $processor_class_name, $enqueued_processors, true ) ) {
+					return $enqueued_processors;
+				}
+				$was_enqueued = true;
+				return array_values( array_diff( $this->sanitize_processor_list( $enqueued_processors ), array( $processor_class_name ) ) );
+			}
+		);
+
+		if ( ! $was_enqueued ) {
 			return false;
 		}

-		$enqueued_processors = array_diff( $enqueued_processors, array( $processor_class_name ) );
-		if ( empty( $enqueued_processors ) ) {
-			$this->force_clear_all_processes();
+		/*
+		 * $remaining_processors is the list exactly as persisted inside the critical section above, so the empty
+		 * check is decided from the lock-guarded snapshot rather than a second, unguarded re-read (which would only
+		 * ever see this request's own post-mutation value anyway). When that snapshot is empty we unschedule every
+		 * single-batch action to sweep up any orphaned "ghost" actions left in Action Scheduler for processors that
+		 * are no longer enqueued; otherwise only the removed processor's own scheduling needs tearing down, so we
+		 * unschedule by class name.
+		 *
+		 * This intentionally does NOT unschedule the watchdog: handle_watchdog_action() returns without rescheduling
+		 * itself once the list is empty (so a lingering watchdog fires at most once more and then stops), and leaving
+		 * it in place is what makes the empty-list sweep safe. force_clear_all_processes() is deliberately avoided:
+		 * its own unguarded read-modify-write would clobber a processor that a concurrent request enqueued in the gap
+		 * after this mutation committed — the exact lost-update race this change exists to prevent.
+		 *
+		 * There is still a narrow window: a concurrent request can enqueue processor Q and have the watchdog schedule
+		 * Q's single-batch action after our lock releases but before the sweep runs, in which case the sweep cancels
+		 * Q's action even though Q remains enqueued. Q is never lost from the option (the source of truth), so this is
+		 * a bounded scheduling delay, not a lost update: the watchdog we leave in place reschedules Q on its next run.
+		 * That recovery is bounded by the watchdog delay (woocommerce_batch_processor_watchdog_delay_seconds), not the
+		 * next request, because remove_or_retry_failed_processors() no-ops while any watchdog is already scheduled.
+		 */
+		if ( empty( $remaining_processors ) ) {
+			as_unschedule_all_actions( self::PROCESS_SINGLE_BATCH_ACTION_NAME );
 		} else {
-			update_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, $enqueued_processors, false );
 			as_unschedule_all_actions( self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) );
-			$this->clear_processor_state( $processor_class_name );
 		}
+		$this->clear_processor_state( $processor_class_name );

 		return true;
 	}
@@ -572,7 +854,12 @@ class BatchProcessingController {
 			return;
 		}

-		$enqueued_processors    = $this->get_enqueued_processors();
+		/*
+		 * Sanitize before array_diff()/array_filter(): array_diff() string-casts its operands (fatal on an object
+		 * entry in PHP 8) and is_scheduled() is typed string, so a corrupted option must be reduced to class-name
+		 * strings first.
+		 */
+		$enqueued_processors    = $this->sanitize_processor_list( $this->get_enqueued_processors() );
 		$unscheduled_processors = array_diff( $enqueued_processors, array_filter( $enqueued_processors, array( $this, 'is_scheduled' ) ) );

 		foreach ( $unscheduled_processors as $processor ) {
diff --git a/plugins/woocommerce/tests/php/src/Internal/BatchProcessing/BatchProcessingControllerTests.php b/plugins/woocommerce/tests/php/src/Internal/BatchProcessing/BatchProcessingControllerTests.php
index 0b184b190ee..9725d8c4c44 100644
--- a/plugins/woocommerce/tests/php/src/Internal/BatchProcessing/BatchProcessingControllerTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/BatchProcessing/BatchProcessingControllerTests.php
@@ -191,7 +191,7 @@ class BatchProcessingControllerTests extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox 'remove_processor' dequeues and unschedules a processor, and unschedules the watchdog if no more more processors are enqueued.
+	 * @testdox 'remove_processor' dequeues and unschedules a processor, and leaves the watchdog to self-terminate when no more processors are enqueued.
 	 */
 	public function test_remove_processor_when_no_others_remain_enqueued() {
 		$this->sut->enqueue_processor( get_class( $this->test_process ) );
@@ -207,7 +207,316 @@ class BatchProcessingControllerTests extends \WC_Unit_Test_Case {

 		$this->assertFalse( $this->sut->is_enqueued( get_class( $this->test_process ) ) );
 		$this->assertFalse( $this->sut->is_scheduled( get_class( $this->test_process ) ) );
-		$this->assertFalse( as_has_scheduled_action( $this->sut::WATCHDOG_ACTION_NAME ) );
+
+		/*
+		 * The watchdog is intentionally left scheduled rather than force-unscheduled: handle_watchdog_action()
+		 * returns without rescheduling itself once the queue is empty (so it self-terminates after one more run),
+		 * and keeping it in place means a processor enqueued concurrently with this removal is still picked up
+		 * instead of being stranded with no watchdog.
+		 */
+		$this->assertTrue(
+			as_has_scheduled_action( $this->sut::WATCHDOG_ACTION_NAME ),
+			'The watchdog should be left to self-terminate, not force-unscheduled, so concurrent enqueues are not stranded.'
+		);
+	}
+
+	/**
+	 * @testdox Enqueuing re-reads the freshest persisted list inside the critical section, merging with a concurrent write instead of clobbering it.
+	 */
+	public function test_enqueue_processor_merges_with_concurrent_write(): void {
+		global $wpdb;
+
+		// This request enqueues A, which also primes the request cache with array( A ).
+		$this->sut->enqueue_processor( 'Processor\\A' );
+
+		/*
+		 * Simulate a concurrent request that appended B and committed it to the database after this request
+		 * had already cached array( A ). Writing through $wpdb (rather than update_option()) deliberately leaves
+		 * the stale request cache in place, reproducing the cross-request read-modify-write race.
+		 */
+		$wpdb->update(
+			$wpdb->options,
+			array( 'option_value' => maybe_serialize( array( 'Processor\\A', 'Processor\\B' ) ) ),
+			array( 'option_name' => BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME )
+		);
+
+		$this->sut->enqueue_processor( 'Processor\\C' );
+
+		$this->assertSame(
+			array( 'Processor\\A', 'Processor\\B', 'Processor\\C' ),
+			$this->sut->get_enqueued_processors(),
+			'Enqueuing must merge with the concurrently-added processor (fresh read) rather than dropping it.'
+		);
+	}
+
+	/**
+	 * @testdox Removing a processor re-reads the freshest list, so a concurrently-added processor is not dropped.
+	 */
+	public function test_remove_processor_uses_fresh_state(): void {
+		global $wpdb;
+
+		update_option(
+			BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME,
+			array( 'Processor\\A', 'Processor\\B' ),
+			false
+		);
+
+		// A concurrent request appended C and committed it after this request cached array( A, B ).
+		$wpdb->update(
+			$wpdb->options,
+			array( 'option_value' => maybe_serialize( array( 'Processor\\A', 'Processor\\B', 'Processor\\C' ) ) ),
+			array( 'option_name' => BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME )
+		);
+
+		$this->sut->remove_processor( 'Processor\\A' );
+
+		$this->assertSame(
+			array( 'Processor\\B', 'Processor\\C' ),
+			$this->sut->get_enqueued_processors(),
+			'Removal must operate on the fresh list, preserving the concurrently-added processor.'
+		);
+	}
+
+	/**
+	 * @testdox Removing a processor from a corrupted list strips non-string values instead of fataling on array_diff().
+	 */
+	public function test_remove_processor_strips_non_string_values_without_fataling(): void {
+		// array_diff() string-casts its operands, so an object entry would fatal in PHP 8 without sanitizing first.
+		update_option(
+			BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME,
+			array( 'Processor\\A', new \stdClass(), array( 'corrupt' ), 'Processor\\B' ),
+			false
+		);
+
+		$this->assertTrue( $this->sut->remove_processor( 'Processor\\A' ) );
+
+		$this->assertSame(
+			array( 'Processor\\B' ),
+			$this->sut->get_enqueued_processors(),
+			'Removal must drop the target and strip non-string values, leaving only valid processor names.'
+		);
+	}
+
+	/**
+	 * @testdox Dequeuing a processor from a corrupted list strips non-string values instead of fataling on array_diff().
+	 */
+	public function test_dequeue_processor_strips_non_string_values_without_fataling(): void {
+		// Mirror of the remove_processor corruption test for the dequeue path, which shares sanitize_processor_list().
+		update_option(
+			BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME,
+			array( 'Processor\\A', new \stdClass(), array( 'corrupt' ), 'Processor\\B' ),
+			false
+		);
+
+		$dequeue = new \ReflectionMethod( $this->sut, 'dequeue_processor' );
+		$dequeue->setAccessible( true );
+		$dequeue->invoke( $this->sut, 'Processor\\A' );
+
+		$this->assertSame(
+			array( 'Processor\\B' ),
+			$this->sut->get_enqueued_processors(),
+			'Dequeuing must drop the target and strip non-string values, leaving only valid processor names.'
+		);
+	}
+
+	/**
+	 * @testdox Dequeuing a finished processor re-reads the freshest list, so a concurrently-added processor is not dropped.
+	 */
+	public function test_dequeue_processor_uses_fresh_state(): void {
+		global $wpdb;
+
+		update_option(
+			BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME,
+			array( 'Processor\\A', 'Processor\\B' ),
+			false
+		);
+
+		// A concurrent request appended C and committed it after this request cached array( A, B ).
+		$wpdb->update(
+			$wpdb->options,
+			array( 'option_value' => maybe_serialize( array( 'Processor\\A', 'Processor\\B', 'Processor\\C' ) ) ),
+			array( 'option_name' => BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME )
+		);
+
+		// dequeue_processor() is private; it is reached in production when a batch finishes. Invoke it directly.
+		$dequeue = new \ReflectionMethod( $this->sut, 'dequeue_processor' );
+		$dequeue->setAccessible( true );
+		$dequeue->invoke( $this->sut, 'Processor\\A' );
+
+		$this->assertSame(
+			array( 'Processor\\B', 'Processor\\C' ),
+			$this->sut->get_enqueued_processors(),
+			'Dequeuing must operate on the fresh list, preserving the concurrently-added processor.'
+		);
+	}
+
+	/**
+	 * @testdox Removing the last processor deletes that processor's stored state.
+	 */
+	public function test_remove_processor_clears_state_when_no_others_remain(): void {
+		$processor = get_class( $this->test_process );
+
+		$this->sut->enqueue_processor( $processor );
+
+		// Seed processor state so we can prove it gets cleared on removal.
+		$state_option = $this->get_processor_state_option_name( $processor );
+		update_option( $state_option, array( 'total_time_spent' => 5 ), false );
+
+		$this->sut->remove_processor( $processor );
+
+		$this->assertFalse(
+			get_option( $state_option ),
+			'Removing the last processor must delete its stored state, not leave it orphaned.'
+		);
+	}
+
+	/**
+	 * @testdox A mutating enqueue holds the options-table lock while persisting and releases it afterwards.
+	 */
+	public function test_enqueue_processor_holds_lock_during_write_and_releases_after(): void {
+		$held_during_write = null;
+		add_filter(
+			'pre_update_option_' . BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME,
+			function ( $value ) use ( &$held_during_write ) {
+				$held_during_write = $this->lock_row_value();
+				return $value;
+			}
+		);
+
+		$this->sut->enqueue_processor( 'Processor\\A' );
+
+		$this->assertNotNull( $held_during_write, 'The lock row must exist while the option is written.' );
+		$this->assertNull( $this->lock_row_value(), 'The lock row must be deleted once the mutation completes.' );
+	}
+
+	/**
+	 * @testdox An unexpired lock claimed by another request forces the mutation onto its best-effort path and is left untouched.
+	 */
+	public function test_enqueue_processor_proceeds_when_lock_held_by_an_unexpired_lock(): void {
+		global $wpdb;
+
+		// Simulate another request holding the lock: insert the lock row with a release time far in the future,
+		// so the controller can neither claim it (row exists) nor take it over (not yet stale) and must fall back.
+		$foreign_expiry = number_format( microtime( true ) + 60, 6, '.', '' );
+		$wpdb->query(
+			$wpdb->prepare(
+				"INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+				BatchProcessingController::ENQUEUED_PROCESSORS_LOCK_OPTION,
+				$foreign_expiry
+			)
+		);
+
+		try {
+			$this->sut->enqueue_processor( 'Processor\\A' );
+
+			$this->assertContains(
+				'Processor\\A',
+				$this->sut->get_enqueued_processors(),
+				'The mutation must still persist when the lock cannot be acquired (best-effort fallback).'
+			);
+			$this->assertNotNull(
+				$this->lock_row_value(),
+				'The other request\'s unexpired lock must be left in place, not stolen or released.'
+			);
+		} finally {
+			$wpdb->query(
+				$wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", BatchProcessingController::ENQUEUED_PROCESSORS_LOCK_OPTION ) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			);
+		}
+	}
+
+	/**
+	 * @testdox A stale (expired) lock left by a crashed request is taken over, and released after the mutation.
+	 */
+	public function test_enqueue_processor_takes_over_stale_lock(): void {
+		global $wpdb;
+
+		// A lock row whose release time is already in the past represents a crashed holder; it must be stealable.
+		$stale_expiry = number_format( microtime( true ) - 60, 6, '.', '' );
+		$wpdb->query(
+			$wpdb->prepare(
+				"INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+				BatchProcessingController::ENQUEUED_PROCESSORS_LOCK_OPTION,
+				$stale_expiry
+			)
+		);
+
+		$this->sut->enqueue_processor( 'Processor\\A' );
+
+		$this->assertContains(
+			'Processor\\A',
+			$this->sut->get_enqueued_processors(),
+			'The mutation must proceed after taking over the stale lock.'
+		);
+		$this->assertNull(
+			$this->lock_row_value(),
+			'After taking over and using the stale lock, the controller must release (delete) it.'
+		);
+	}
+
+	/**
+	 * @testdox Releasing does not delete a lock whose stored release time no longer matches ours (taken over by another request).
+	 */
+	public function test_release_leaves_a_lock_taken_over_by_another_request(): void {
+		global $wpdb;
+
+		$foreign_value = null;
+		add_filter(
+			'pre_update_option_' . BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME,
+			function ( $value ) use ( &$foreign_value, $wpdb ) {
+				// While this request holds the lock, simulate another request expiring and taking it over by
+				// overwriting the lock row's release time. Release must then leave that row alone.
+				$foreign_value = number_format( microtime( true ) + 999, 6, '.', '' );
+				$wpdb->update(
+					$wpdb->options,
+					array( 'option_value' => $foreign_value ),
+					array( 'option_name' => BatchProcessingController::ENQUEUED_PROCESSORS_LOCK_OPTION ),
+					array( '%s' ),
+					array( '%s' )
+				);
+				return $value;
+			}
+		);
+
+		$this->sut->enqueue_processor( 'Processor\\A' );
+
+		$this->assertSame(
+			$foreign_value,
+			$this->lock_row_value(),
+			'Release must be scoped to our own release time, so a lock another request now owns is not deleted.'
+		);
+
+		// Clean up the simulated foreign lock so it does not leak into other assertions.
+		$wpdb->delete(
+			$wpdb->options,
+			array( 'option_name' => BatchProcessingController::ENQUEUED_PROCESSORS_LOCK_OPTION ),
+			array( '%s' )
+		);
+	}
+
+	/**
+	 * Read the raw stored value of the enqueued-processors lock row, or null when no lock is held.
+	 *
+	 * @return string|null The lock row's option_value, or null if the row does not exist.
+	 */
+	private function lock_row_value(): ?string {
+		global $wpdb;
+		$value = $wpdb->get_var(
+			$wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", BatchProcessingController::ENQUEUED_PROCESSORS_LOCK_OPTION ) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		);
+		return null === $value ? null : (string) $value;
+	}
+
+	/**
+	 * Resolve the option name where a processor's state is stored, via reflection.
+	 *
+	 * @param string $processor_class_name Fully qualified processor class name.
+	 * @return string Option name.
+	 */
+	private function get_processor_state_option_name( string $processor_class_name ): string {
+		$method = new \ReflectionMethod( $this->sut, 'get_processor_state_option_name' );
+		$method->setAccessible( true );
+		return $method->invoke( $this->sut, $processor_class_name );
 	}

 	/**
@@ -332,4 +641,108 @@ class BatchProcessingControllerTests extends \WC_Unit_Test_Case {
 		$this->assertFalse( $this->sut->is_scheduled( get_class( $second_processor ) ) );
 		$this->assertFalse( as_has_scheduled_action( $this->sut::WATCHDOG_ACTION_NAME ) );
 	}
+
+	/**
+	 * @testdox The watchdog sanitizes a corrupted enqueued list, scheduling each valid processor once without fataling on non-string or empty entries.
+	 */
+	public function test_handle_watchdog_action_sanitizes_corrupted_list(): void {
+		// A raw option holding duplicates, a non-string, an empty string, and a corrupt array entry: without
+		// sanitizing, the non-string would fatal when passed to the strictly-typed is_scheduled(), and the empty
+		// string would be scheduled and later fatal in get_processor_instance( '' ).
+		update_option(
+			BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME,
+			array( 'Processor\\A', 'Processor\\A', new \stdClass(), '', array( 'corrupt' ), 'Processor\\B' ),
+			false
+		);
+
+		//phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		do_action( $this->sut::WATCHDOG_ACTION_NAME );
+
+		$this->assertTrue( $this->sut->is_scheduled( 'Processor\\A' ), 'A valid processor should be scheduled by the watchdog.' );
+		$this->assertTrue( $this->sut->is_scheduled( 'Processor\\B' ), 'A valid processor should be scheduled by the watchdog.' );
+		$this->assertFalse(
+			as_has_scheduled_action( $this->sut::PROCESS_SINGLE_BATCH_ACTION_NAME, array( '' ) ),
+			'An empty-string entry must be dropped by sanitization, not scheduled as a processor.'
+		);
+		$this->assertCount(
+			2,
+			as_get_scheduled_actions(
+				array(
+					'hook'     => $this->sut::PROCESS_SINGLE_BATCH_ACTION_NAME,
+					'group'    => '',
+					'status'   => \ActionScheduler_Store::STATUS_PENDING,
+					'per_page' => -1,
+				),
+				'ids'
+			),
+			'Only the two valid processors should be scheduled; duplicate, empty-string, and non-string entries must be dropped.'
+		);
+	}
+
+	/**
+	 * @testdox Removing the last enqueued processor sweeps orphaned 'ghost' single-batch actions left for processors no longer enqueued.
+	 */
+	public function test_remove_processor_sweeps_ghost_actions_when_queue_empties(): void {
+		$this->sut->enqueue_processor( get_class( $this->test_process ) );
+
+		//phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		do_action( $this->sut::WATCHDOG_ACTION_NAME );
+
+		// A leftover single-batch action for a processor that is no longer enqueued, e.g. from the historical corruption bug.
+		as_schedule_single_action( time() + HOUR_IN_SECONDS, $this->sut::PROCESS_SINGLE_BATCH_ACTION_NAME, array( 'Ghost\\Processor' ) );
+
+		$this->assertTrue( $this->sut->is_scheduled( get_class( $this->test_process ) ) );
+		$this->assertTrue( as_has_scheduled_action( $this->sut::PROCESS_SINGLE_BATCH_ACTION_NAME, array( 'Ghost\\Processor' ) ) );
+
+		$this->sut->remove_processor( get_class( $this->test_process ) );
+
+		$this->assertFalse( $this->sut->is_scheduled( get_class( $this->test_process ) ), 'The removed processor should be unscheduled.' );
+		$this->assertFalse(
+			as_has_scheduled_action( $this->sut::PROCESS_SINGLE_BATCH_ACTION_NAME, array( 'Ghost\\Processor' ) ),
+			'Emptying the queue should sweep orphaned ghost single-batch actions.'
+		);
+		$this->assertCount(
+			0,
+			as_get_scheduled_actions(
+				array(
+					'hook'     => $this->sut::PROCESS_SINGLE_BATCH_ACTION_NAME,
+					'group'    => '',
+					'status'   => \ActionScheduler_Store::STATUS_PENDING,
+					'per_page' => -1,
+				),
+				'ids'
+			),
+			'Emptying the queue should sweep every single-batch action, not just the removed processor.'
+		);
+		$this->assertTrue(
+			as_has_scheduled_action( $this->sut::WATCHDOG_ACTION_NAME ),
+			'The watchdog should be left scheduled so a concurrent enqueue is not stranded.'
+		);
+	}
+
+	/**
+	 * @testdox Removing a processor while others remain enqueued unschedules only its own action, leaving siblings scheduled.
+	 */
+	public function test_remove_processor_leaves_siblings_scheduled_when_queue_not_empty(): void {
+		$second_processor = $this->get_processor_stub();
+
+		$this->sut->enqueue_processor( get_class( $this->test_process ) );
+		$this->sut->enqueue_processor( get_class( $second_processor ) );
+
+		//phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		do_action( $this->sut::WATCHDOG_ACTION_NAME );
+
+		$this->assertTrue( $this->sut->is_scheduled( get_class( $this->test_process ) ) );
+		$this->assertTrue( $this->sut->is_scheduled( get_class( $second_processor ) ) );
+
+		$this->sut->remove_processor( get_class( $this->test_process ) );
+
+		$this->assertFalse( $this->sut->is_scheduled( get_class( $this->test_process ) ), 'The removed processor should be unscheduled.' );
+		$this->assertFalse( $this->sut->is_enqueued( get_class( $this->test_process ) ), 'The removed processor should also be dequeued.' );
+		$this->assertTrue(
+			$this->sut->is_scheduled( get_class( $second_processor ) ),
+			'A still-enqueued sibling processor must not have its scheduled action swept.'
+		);
+		$this->assertTrue( $this->sut->is_enqueued( get_class( $second_processor ) ), 'The sibling processor should remain enqueued.' );
+	}
 }