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.' );
+ }
}