rivate function is_consistently_failing( BatchProcessorInterface $batch_processor ): bool { $process_details = $this->get_process_details( $batch_processor ); $max_attempts = absint( /** * Controls the failure threshold for batch processors. That is, the number of times we'll attempt to * process a batch that has resulted in a failure. Once above this threshold, the processor won't be * re-scheduled and will be removed from the queue. * * @since 9.1.0 * * @param int $failure_threshold Maximum number of times for the processor to try processing a given batch. * @param BatchProcessorInterface $batch_processor The processor instance. * @param array $process_details Array with batch processor state. */ apply_filters( 'wc_batch_processing_max_attempts', self::FAILING_PROCESS_MAX_ATTEMPTS_DEFAULT, $batch_processor, $process_details ) ); return absint( $process_details['recent_failures'] ?? 0 ) >= max( $max_attempts, 1 ); } /** * Creates log entry with details about a batch processor that is consistently failing. * * @since 9.1.0 * * @param BatchProcessorInterface $batch_processor The batch processor instance. * @param array $process_details Failing process details. */ private function log_consistent_failure( BatchProcessorInterface $batch_processor, array $process_details ): void { $this->logger->error( "Batch processor {$batch_processor->get_name()} appears to be failing consistently: {$process_details['recent_failures']} unsuccessful attempt(s). No further attempts will be made.", array( 'source' => 'batch-processing', 'failures' => $process_details['recent_failures'], 'first_failure' => $process_details['batch_first_failure'], 'last_failure' => $process_details['batch_last_failure'], ) ); } /** * Hooked onto 'shutdown'. This cleanup routine checks enqueued processors and whether they are scheduled or not to * either re-eschedule them or remove them from the queue. * This prevents stale states where Action Scheduler won't schedule any more attempts but we still report the * processor as enqueued. * * @since 9.1.0 */ private function remove_or_retry_failed_processors(): void { if ( ! did_action( 'wp_loaded' ) ) { return; } $last_error = error_get_last(); if ( ! is_null( $last_error ) && in_array( $last_error['type'], array( E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR ), true ) ) { return; } // Everything below reads "not scheduled" as grounds for recording a failure against a processor // and eventually dropping it from the queue. Action Scheduler being unloaded also reads as // "not scheduled", so bail rather than dismantle the queue over a missing dependency. if ( ! ActionSchedulerUtil::can_check_scheduled_actions() ) { return; } // 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; } $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 { $instance = $this->get_processor_instance( $processor ); } catch ( \Exception $e ) { continue; } $exception = new \Exception( 'Processor is enqueued but not scheduled. Background job was probably killed or marked as failed. Reattempting execution.' ); $this->update_processor_state( $instance, 0, $exception ); $this->log_error( $exception, $instance, array() ); if ( $this->is_consistently_failing( $instance ) ) { $this->log_consistent_failure( $instance, $this->get_process_details( $instance ) ); $this->remove_processor( $processor ); } else { $this->schedule_batch_processing( $processor, true ); } } } /** * 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; } }