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