Commit b47af4519ac for woocommerce

commit b47af4519acca1d182849a3a8957c78f03235839
Author: Darren Ethier <darren@roughsmootheng.in>
Date:   Wed Sep 30 07:00:31 2026 -0400

    Skip idle batch recovery checks and stop cleanup after failed scheduler lookups (#68959)

diff --git a/plugins/woocommerce/changelog/fix-wooplug-7791-shutdown-db-failures b/plugins/woocommerce/changelog/fix-wooplug-7791-shutdown-db-failures
new file mode 100644
index 00000000000..b9e392cb043
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-7791-shutdown-db-failures
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Skip background processing checks when nothing is queued, and avoid false failures when the database is unavailable.
diff --git a/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php b/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php
index 0f60d557fcc..33c3fcbf2f6 100644
--- a/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php
+++ b/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php
@@ -855,17 +855,28 @@ class BatchProcessingController {
 			return;
 		}

-		if ( ActionSchedulerUtil::has_scheduled_action( self::WATCHDOG_ACTION_NAME ) ) {
+		// Normalize corrupted option values before passing processor names to is_scheduled().
+		$enqueued_processors = $this->sanitize_processor_list( $this->get_enqueued_processors() );
+		if ( empty( $enqueued_processors ) ) {
 			return;
 		}

-		/*
-		 * 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' ) ) );
+		$watchdog_scheduled = $this->run_scheduler_lookup( fn() => ActionSchedulerUtil::has_scheduled_action( self::WATCHDOG_ACTION_NAME ) );
+		if ( false !== $watchdog_scheduled ) {
+			return;
+		}
+
+		$unscheduled_processors = array();
+		foreach ( $enqueued_processors as $processor ) {
+			$is_scheduled = $this->run_scheduler_lookup( fn() => $this->is_scheduled( $processor ) );
+			// A failed lookup is not evidence of a failed processor. Finish all checks before changing state.
+			if ( null === $is_scheduled ) {
+				return;
+			}
+			if ( ! $is_scheduled ) {
+				$unscheduled_processors[] = $processor;
+			}
+		}

 		foreach ( $unscheduled_processors as $processor ) {
 			try {
@@ -886,4 +897,30 @@ class BatchProcessingController {
 			}
 		}
 	}
+
+	/**
+	 * Run an Action Scheduler lookup, distinguishing "not scheduled" from a failed database query.
+	 *
+	 * Action Scheduler returns the same value for both, so the database state is checked directly. $wpdb->last_error
+	 * only counts when the lookup ran a query: otherwise (e.g. a custom store that bypasses $wpdb) it is left over
+	 * from an earlier, unrelated query.
+	 *
+	 * @param callable $lookup Callback performing the lookup.
+	 * @return bool|null The lookup result, or null if the lookup failed.
+	 */
+	private function run_scheduler_lookup( callable $lookup ): ?bool {
+		global $wpdb;
+
+		$queries_before = $wpdb->num_queries;
+		$result         = (bool) $lookup();
+		$query_failed   = $wpdb->num_queries > $queries_before && ! empty( $wpdb->last_error );
+
+		// When reconnecting after a lost connection fails, wpdb::query() returns false without an error but discards
+		// the connection handle. A later query can reconnect, so state changes would succeed on top of this failed read.
+		if ( $query_failed || empty( $wpdb->dbh ) ) {
+			return null;
+		}
+
+		return $result;
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/BatchProcessing/BatchProcessingControllerTests.php b/plugins/woocommerce/tests/php/src/Internal/BatchProcessing/BatchProcessingControllerTests.php
index 2f9aa8c9403..5bdd27eb010 100644
--- a/plugins/woocommerce/tests/php/src/Internal/BatchProcessing/BatchProcessingControllerTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/BatchProcessing/BatchProcessingControllerTests.php
@@ -738,4 +738,154 @@ class BatchProcessingControllerTests extends \WC_Unit_Test_Case {
 		);
 		$this->assertTrue( $this->sut->is_enqueued( get_class( $second_processor ) ), 'The sibling processor should remain enqueued.' );
 	}
+	/**
+	 * @testdox Shutdown cleanup does not query Action Scheduler for an empty queue.
+	 */
+	public function test_shutdown_skips_scheduler_for_empty_queue(): void {
+		$lookups = 0;
+		$filter  = function ( $query ) use ( &$lookups ) {
+			if ( false !== strpos( $query, 'actionscheduler_actions' ) ) {
+				++$lookups;
+			}
+			return $query;
+		};
+		add_filter( 'query', $filter );
+		try {
+			$this->run_shutdown_cleanup();
+		} finally {
+			remove_filter( 'query', $filter );
+		}
+
+		$this->assertSame( 0, $lookups );
+	}
+
+	/**
+	 * @testdox A failed scheduler lookup leaves every processor's state and schedule untouched.
+	 * @dataProvider failed_shutdown_lookup_provider
+	 * @param int $failed_lookup The lookup to fail, starting with the watchdog.
+	 */
+	public function test_shutdown_preserves_processors_on_failed_lookup( int $failed_lookup ): void {
+		global $wpdb;
+
+		$processors = array( get_class( $this->test_process ), get_class( $this->get_processor_stub() ) );
+		update_option( BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME, $processors, false );
+		$lookups         = 0;
+		$filter          = function ( $query ) use ( &$lookups, $failed_lookup ) {
+			if ( false !== strpos( $query, 'SELECT a.action_id' ) && false !== strpos( $query, 'actionscheduler_actions' ) ) {
+				++$lookups;
+				if ( $failed_lookup === $lookups ) {
+					return 'SELECT * FROM wooplug_7791_missing_table';
+				}
+			}
+			return $query;
+		};
+		$suppress_errors = $wpdb->suppress_errors( true );
+		add_filter( 'query', $filter );
+		try {
+			$this->run_shutdown_cleanup();
+		} finally {
+			remove_filter( 'query', $filter );
+			$wpdb->suppress_errors( $suppress_errors );
+			$wpdb->flush();
+		}
+
+		$this->assertSame( $failed_lookup, $lookups, 'The failing lookup must run, with no further lookups after it.' );
+		$this->assertSame( $processors, $this->sut->get_enqueued_processors() );
+		foreach ( $processors as $processor ) {
+			$this->assertFalse( get_option( $this->get_processor_state_option_name( $processor ) ), 'A failed lookup must not record a processor failure.' );
+			$this->assertFalse( $this->sut->is_scheduled( $processor ), 'A failed lookup must not schedule a retry.' );
+		}
+	}
+
+	/**
+	 * Scheduler lookup failures to exercise.
+	 *
+	 * @return array
+	 */
+	public function failed_shutdown_lookup_provider(): array {
+		return array(
+			'watchdog'         => array( 1 ),
+			'first processor'  => array( 2 ),
+			'second processor' => array( 3 ),
+		);
+	}
+
+	/**
+	 * @testdox Shutdown cleanup still retries an unscheduled processor after successful lookups.
+	 */
+	public function test_shutdown_retries_unscheduled_processor(): void {
+		$processor = get_class( $this->test_process );
+		update_option( BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME, array( $processor ), false );
+
+		$this->run_shutdown_cleanup();
+
+		$this->assertTrue( $this->sut->is_scheduled( $processor ) );
+		$details = get_option( $this->get_processor_state_option_name( $processor ) );
+		$this->assertSame( 1, $details['recent_failures'] );
+	}
+
+	/**
+	 * @testdox A database error from before the lookups does not block cleanup when the lookups never reach $wpdb.
+	 */
+	public function test_shutdown_ignores_stale_db_error(): void {
+		global $wpdb;
+
+		$processor = get_class( $this->test_process );
+		update_option( BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME, array( $processor ), false );
+		// Emptied lookup queries (both hybrid store tables) return before $wpdb->flush(), like a custom store that
+		// bypasses $wpdb, so an error left by an earlier query in the request is still set during the lookups.
+		$filter = function ( $query ) {
+			return str_starts_with( $query, 'SELECT p.ID FROM' ) || str_starts_with( $query, 'SELECT a.action_id FROM' ) ? '' : $query;
+		};
+		add_filter( 'query', $filter );
+		try {
+			$wpdb->last_error = 'Stale error from an unrelated query.';
+			$this->run_shutdown_cleanup();
+		} finally {
+			remove_filter( 'query', $filter );
+			$wpdb->flush();
+		}
+
+		$details = get_option( $this->get_processor_state_option_name( $processor ) );
+		$this->assertSame( 1, $details['recent_failures'], 'A stale error must not be treated as a failed lookup.' );
+	}
+
+	/**
+	 * @testdox A failed reconnect during a lookup leaves the processor's state and schedule untouched.
+	 */
+	public function test_shutdown_preserves_processors_on_failed_reconnect(): void {
+		global $wpdb;
+
+		$processor = get_class( $this->test_process );
+		update_option( BatchProcessingController::ENQUEUED_PROCESSORS_OPTION_NAME, array( $processor ), false );
+		$dbh = $wpdb->dbh;
+		// A failed reconnect returns false without an error and discards the handle; the next query reconnects.
+		$filter = function ( $query ) use ( $wpdb ) {
+			if ( false === strpos( $query, 'SELECT a.action_id' ) ) {
+				return $query;
+			}
+			$wpdb->dbh = null;
+			return '';
+		};
+		add_filter( 'query', $filter );
+		try {
+			$this->run_shutdown_cleanup();
+		} finally {
+			remove_filter( 'query', $filter );
+			$wpdb->dbh = $dbh;
+		}
+
+		$this->assertSame( array( $processor ), $this->sut->get_enqueued_processors() );
+		$this->assertFalse( get_option( $this->get_processor_state_option_name( $processor ) ), 'A failed lookup must not record a processor failure.' );
+		$this->assertFalse( $this->sut->is_scheduled( $processor ), 'A failed lookup must not schedule a retry.' );
+	}
+
+	/**
+	 * Run only this controller's shutdown callback, avoiding unrelated shutdown handlers.
+	 */
+	private function run_shutdown_cleanup(): void {
+		$method = new \ReflectionMethod( $this->sut, 'remove_or_retry_failed_processors' );
+		$method->setAccessible( true );
+		$method->invoke( $this->sut );
+	}
 }