410 lines
16 KiB
PHP
410 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* This class is a helper intended to handle data processings that need to happen in batches in a deferred way.
|
|
* It abstracts away the nuances of (re)scheduling actions and dealing with errors.
|
|
*
|
|
* Usage:
|
|
*
|
|
* 1. Create a class that implements BatchProcessorInterface.
|
|
* The class must either be registered in the dependency injection container, or have a public parameterless constructor,
|
|
* or an instance must be provided via the 'woocommerce_get_batch_processor' filter.
|
|
* 2. Whenever there's data to be processed invoke the 'enqueue_processor' method in this class,
|
|
* passing the class name of the processor.
|
|
*
|
|
* That's it, processing will be performed in batches inside scheduled actions; enqueued processors will only
|
|
* be dequeued once they notify that no more items are left to process (or when `force_clear_all_processes` is invoked).
|
|
* Failed batches will be retried after a while.
|
|
*
|
|
* There are also a few public methods to get the list of currently enqueued processors
|
|
* and to check if a given processor is enqueued/actually scheduled.
|
|
*/
|
|
|
|
namespace Automattic\WooCommerce\Internal\BatchProcessing;
|
|
|
|
/**
|
|
* Class BatchProcessingController
|
|
*
|
|
* @package Automattic\WooCommerce\Internal\Updates.
|
|
*/
|
|
class BatchProcessingController {
|
|
/*
|
|
* Identifier of a "watchdog" action that will schedule a processing action
|
|
* for any processor that is enqueued but not yet scheduled
|
|
* (because it's been just enqueued or because it threw an error while processing a batch),
|
|
* that's one single action that reschedules itself continuously.
|
|
*/
|
|
const WATCHDOG_ACTION_NAME = 'wc_schedule_pending_batch_processes';
|
|
|
|
/*
|
|
* Identifier of the action that will do the actual batch processing.
|
|
* There's one action per enqueued processor that will keep rescheduling itself
|
|
* as long as there are still pending items to process
|
|
* (except if there's an error that caused no items to be processed at all).
|
|
*/
|
|
const PROCESS_SINGLE_BATCH_ACTION_NAME = 'wc_run_batch_process';
|
|
|
|
const ENQUEUED_PROCESSORS_OPTION_NAME = 'wc_pending_batch_processes';
|
|
const ACTION_GROUP = 'wc_batch_processes';
|
|
|
|
/**
|
|
* Instance of WC_Logger class.
|
|
*
|
|
* @var \WC_Logger_Interface
|
|
*/
|
|
private $logger;
|
|
|
|
/**
|
|
* BatchProcessingController constructor.
|
|
*
|
|
* Schedules the necessary actions to process batches.
|
|
*/
|
|
public function __construct() {
|
|
add_action(
|
|
self::WATCHDOG_ACTION_NAME,
|
|
function () {
|
|
$this->handle_watchdog_action();
|
|
}
|
|
);
|
|
|
|
add_action(
|
|
self::PROCESS_SINGLE_BATCH_ACTION_NAME,
|
|
function ( $batch_process ) {
|
|
$this->process_next_batch_for_single_processor( $batch_process );
|
|
},
|
|
10,
|
|
2
|
|
);
|
|
|
|
$this->logger = wc_get_logger();
|
|
}
|
|
|
|
/**
|
|
* Enqueue a processor so that it will get batch processing requests from within scheduled actions.
|
|
*
|
|
* @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();
|
|
if ( ! in_array( $processor_class_name, array_keys( $pending_updates ), true ) ) {
|
|
$pending_updates[] = $processor_class_name;
|
|
$this->set_enqueued_processors( $pending_updates );
|
|
}
|
|
$this->schedule_watchdog_action( false, true );
|
|
}
|
|
|
|
/**
|
|
* Schedule the watchdog action.
|
|
*
|
|
* @param bool $with_delay Whether to delay the action execution. Should be true when rescheduling, false when enqueueing.
|
|
* @param bool $unique Whether to make the action unique.
|
|
*/
|
|
private function schedule_watchdog_action( bool $with_delay = false, bool $unique = false ): void {
|
|
$time = time();
|
|
if ( $with_delay ) {
|
|
/**
|
|
* Modify the delay interval for the batch processor's watchdog events.
|
|
*
|
|
* @since 8.2.0
|
|
*
|
|
* @param int $delay Time, in seconds, before the watchdog process will run. Defaults to 3600 (1 hour).
|
|
*/
|
|
$time += apply_filters( 'woocommerce_batch_processor_watchdog_delay_seconds', HOUR_IN_SECONDS );
|
|
}
|
|
|
|
if ( ! as_has_scheduled_action( self::WATCHDOG_ACTION_NAME ) ) {
|
|
as_schedule_single_action(
|
|
$time,
|
|
self::WATCHDOG_ACTION_NAME,
|
|
array(),
|
|
self::ACTION_GROUP,
|
|
$unique
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule a processing action for all the processors that are enqueued but not scheduled
|
|
* (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();
|
|
if ( empty( $pending_processes ) ) {
|
|
return;
|
|
}
|
|
foreach ( $pending_processes as $process_name ) {
|
|
if ( ! $this->is_scheduled( $process_name ) ) {
|
|
$this->schedule_batch_processing( $process_name );
|
|
}
|
|
}
|
|
$this->schedule_watchdog_action( true );
|
|
}
|
|
|
|
/**
|
|
* Process a batch for a single processor, and handle any required rescheduling or state cleanup.
|
|
*
|
|
* @param string $processor_class_name Fully qualified class name of the processor.
|
|
*
|
|
* @throws \Exception If error occurred during batch processing.
|
|
*/
|
|
private function process_next_batch_for_single_processor( string $processor_class_name ): void {
|
|
if ( ! $this->is_enqueued( $processor_class_name ) ) {
|
|
return;
|
|
}
|
|
|
|
$batch_processor = $this->get_processor_instance( $processor_class_name );
|
|
$error = $this->process_next_batch_for_single_processor_core( $batch_processor );
|
|
$still_pending = count( $batch_processor->get_next_batch_to_process( 1 ) ) > 0;
|
|
if ( ( $error instanceof \Exception ) ) {
|
|
// The batch processing failed and no items were processed:
|
|
// reschedule the processing with a delay, and also throw the error
|
|
// so Action Scheduler will ignore the rescheduling if this happens repeatedly.
|
|
$this->schedule_batch_processing( $processor_class_name, true );
|
|
throw $error;
|
|
}
|
|
if ( $still_pending ) {
|
|
$this->schedule_batch_processing( $processor_class_name );
|
|
} else {
|
|
$this->dequeue_processor( $processor_class_name );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process a batch for a single processor, updating state and logging any error.
|
|
*
|
|
* @param BatchProcessorInterface $batch_processor Batch processor instance.
|
|
*
|
|
* @return null|\Exception Exception if error occurred, null otherwise.
|
|
*/
|
|
private function process_next_batch_for_single_processor_core( BatchProcessorInterface $batch_processor ): ?\Exception {
|
|
$details = $this->get_process_details( $batch_processor );
|
|
$time_start = microtime( true );
|
|
$batch = $batch_processor->get_next_batch_to_process( $details['current_batch_size'] );
|
|
if ( empty( $batch ) ) {
|
|
return null;
|
|
}
|
|
try {
|
|
$batch_processor->process_batch( $batch );
|
|
$time_taken = microtime( true ) - $time_start;
|
|
$this->update_processor_state( $batch_processor, $time_taken );
|
|
} catch ( \Exception $exception ) {
|
|
$time_taken = microtime( true ) - $time_start;
|
|
$this->log_error( $exception, $batch_processor, $batch );
|
|
$this->update_processor_state( $batch_processor, $time_taken, $exception );
|
|
return $exception;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get the current state for a given enqueued processor.
|
|
*
|
|
* @param BatchProcessorInterface $batch_processor Batch processor instance.
|
|
*
|
|
* @return array Current state for the processor, or a "blank" state if none exists yet.
|
|
*/
|
|
private function get_process_details( BatchProcessorInterface $batch_processor ): array {
|
|
return get_option(
|
|
$this->get_processor_state_option_name( $batch_processor ),
|
|
array(
|
|
'total_time_spent' => 0,
|
|
'current_batch_size' => $batch_processor->get_default_batch_size(),
|
|
'last_error' => null,
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the name of the option where we will be saving state for a given processor.
|
|
*
|
|
* @param BatchProcessorInterface $batch_processor Batch processor instance.
|
|
*
|
|
* @return string Option name.
|
|
*/
|
|
private function get_processor_state_option_name( BatchProcessorInterface $batch_processor ): string {
|
|
$class_name = get_class( $batch_processor );
|
|
$class_md5 = md5( $class_name );
|
|
// truncate the class name so we know that it will fit in the option name column along with md5 hash and prefix.
|
|
$class_name = substr( $class_name, 0, 140 );
|
|
return 'wc_batch_' . $class_name . '_' . $class_md5;
|
|
}
|
|
|
|
/**
|
|
* Update the state for a processor after a batch has completed processing.
|
|
*
|
|
* @param BatchProcessorInterface $batch_processor Batch processor instance.
|
|
* @param float $time_taken Time take by the batch to complete processing.
|
|
* @param \Exception|null $last_error Exception object in processing the batch, if there was one.
|
|
*/
|
|
private function update_processor_state( BatchProcessorInterface $batch_processor, float $time_taken, \Exception $last_error = null ): void {
|
|
$current_status = $this->get_process_details( $batch_processor );
|
|
$current_status['total_time_spent'] += $time_taken;
|
|
$current_status['last_error'] = null !== $last_error ? $last_error->getMessage() : null;
|
|
update_option( $this->get_processor_state_option_name( $batch_processor ), $current_status, false );
|
|
}
|
|
|
|
/**
|
|
* Schedule a processing action for a single processor.
|
|
*
|
|
* @param string $processor_class_name Fully qualified class name of the processor.
|
|
* @param bool $with_delay Whether to schedule the action for immediate execution or for later.
|
|
*/
|
|
private function schedule_batch_processing( string $processor_class_name, bool $with_delay = false ) : void {
|
|
$time = $with_delay ? time() + MINUTE_IN_SECONDS : time();
|
|
as_schedule_single_action( $time, self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) );
|
|
}
|
|
|
|
/**
|
|
* Check if a batch processing action is already scheduled for a given processor.
|
|
* Differs from `as_has_scheduled_action` in that this excludes actions in progress.
|
|
*
|
|
* @param string $processor_class_name Fully qualified class name of the batch processor.
|
|
*
|
|
* @return bool True if a batch processing action is already scheduled for the processor.
|
|
*/
|
|
public function is_scheduled( string $processor_class_name ): bool {
|
|
return as_has_scheduled_action( self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) );
|
|
}
|
|
|
|
/**
|
|
* Get an instance of a processor given its class name.
|
|
*
|
|
* @param string $processor_class_name Full class name of the batch processor.
|
|
*
|
|
* @return BatchProcessorInterface Instance of batch processor for the given class.
|
|
* @throws \Exception If it's not possible to get an instance of the class.
|
|
*/
|
|
private function get_processor_instance( string $processor_class_name ) : BatchProcessorInterface {
|
|
$processor = wc_get_container()->get( $processor_class_name );
|
|
|
|
/**
|
|
* Filters the instance of a processor for a given class name.
|
|
*
|
|
* @param object|null $processor The processor instance given by the dependency injection container, or null if none was obtained.
|
|
* @param string $processor_class_name The full class name of the processor.
|
|
* @return BatchProcessorInterface|null The actual processor instance to use, or null if none could be retrieved.
|
|
*
|
|
* @since 6.8.0.
|
|
*/
|
|
$processor = apply_filters( 'woocommerce_get_batch_processor', $processor, $processor_class_name );
|
|
if ( ! isset( $processor ) && class_exists( $processor_class_name ) ) {
|
|
// This is a fallback for when the batch processor is not registered in the container.
|
|
$processor = new $processor_class_name();
|
|
}
|
|
if ( ! is_a( $processor, BatchProcessorInterface::class ) ) {
|
|
throw new \Exception( "Unable to initialize batch processor instance for $processor_class_name" );
|
|
}
|
|
return $processor;
|
|
}
|
|
|
|
/**
|
|
* Helper method to get list of all the enqueued processors.
|
|
*
|
|
* @return array List (of string) of the class names of the enqueued processors.
|
|
*/
|
|
public function get_enqueued_processors() : array {
|
|
return get_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, array() );
|
|
}
|
|
|
|
/**
|
|
* Dequeue a processor once it has no more items pending processing.
|
|
*
|
|
* @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 ) ) {
|
|
$pending_processes = array_diff( $pending_processes, array( $processor_class_name ) );
|
|
$this->set_enqueued_processors( $pending_processes );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper method to set the enqueued processor class names.
|
|
*
|
|
* @param array $processors List of full processor class names.
|
|
*/
|
|
private function set_enqueued_processors( array $processors ): void {
|
|
update_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, $processors, false );
|
|
}
|
|
|
|
/**
|
|
* Check if a particular processor is enqueued.
|
|
*
|
|
* @param string $processor_class_name Fully qualified class name of the processor.
|
|
*
|
|
* @return bool True if the processor is enqueued.
|
|
*/
|
|
public function is_enqueued( string $processor_class_name ) : bool {
|
|
return in_array( $processor_class_name, $this->get_enqueued_processors(), true );
|
|
}
|
|
|
|
/**
|
|
* Dequeue and de-schedule a processor instance so that it won't be processed anymore.
|
|
*
|
|
* @param string $processor_class_name Fully qualified class name of the processor.
|
|
* @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 ) ) {
|
|
return false;
|
|
}
|
|
|
|
$enqueued_processors = array_diff( $enqueued_processors, array( $processor_class_name ) );
|
|
if ( empty( $enqueued_processors ) ) {
|
|
$this->force_clear_all_processes();
|
|
} 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 ) );
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Dequeues and de-schedules all the processors.
|
|
*/
|
|
public function force_clear_all_processes(): void {
|
|
as_unschedule_all_actions( self::PROCESS_SINGLE_BATCH_ACTION_NAME );
|
|
as_unschedule_all_actions( self::WATCHDOG_ACTION_NAME );
|
|
update_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, array(), false );
|
|
}
|
|
|
|
/**
|
|
* Log an error that happened while processing a batch.
|
|
*
|
|
* @param \Exception $error Exception object to log.
|
|
* @param BatchProcessorInterface $batch_processor Batch processor instance.
|
|
* @param array $batch Batch that was being processed.
|
|
*/
|
|
protected function log_error( \Exception $error, BatchProcessorInterface $batch_processor, array $batch ) : void {
|
|
$batch_detail_string = '';
|
|
// Log only first and last, as the entire batch may be too big.
|
|
if ( count( $batch ) > 0 ) {
|
|
$batch_detail_string = "\n" . wp_json_encode(
|
|
array(
|
|
'batch_start' => $batch[0],
|
|
'batch_end' => end( $batch ),
|
|
),
|
|
JSON_PRETTY_PRINT
|
|
);
|
|
}
|
|
$error_message = "Error processing batch for {$batch_processor->get_name()}: {$error->getMessage()}" . $batch_detail_string;
|
|
|
|
/**
|
|
* Filters the error message for a batch processing.
|
|
*
|
|
* @param string $error_message The error message that will be logged.
|
|
* @param \Exception $error The exception that was thrown by the processor.
|
|
* @param BatchProcessorInterface $batch_processor The processor that threw the exception.
|
|
* @param array $batch The batch that was being processed.
|
|
* @return string The actual error message that will be logged.
|
|
*
|
|
* @since 6.8.0
|
|
*/
|
|
$error_message = apply_filters( 'wc_batch_processing_log_message', $error_message, $error, $batch_processor, $batch );
|
|
|
|
$this->logger->error( $error_message, array( 'exception' => $error ) );
|
|
}
|
|
}
|