From 49ee6f407e6bc01a93d280e82453d77f96826c3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Perona?= Date: Sun, 28 Jan 2024 15:18:11 -0500 Subject: [PATCH] update WP background processing and AS --- .../ActionScheduler/action-scheduler.php | 19 +- .../ActionScheduler/changelog.txt | 60 +++ .../classes/ActionScheduler_ActionFactory.php | 108 ++++- .../classes/ActionScheduler_Compatibility.php | 18 +- .../classes/ActionScheduler_ListTable.php | 19 +- .../classes/ActionScheduler_OptionLock.php | 90 +++- .../classes/ActionScheduler_QueueCleaner.php | 127 ++++-- .../classes/ActionScheduler_QueueRunner.php | 19 +- .../ActionScheduler_WPCLI_Clean_Command.php | 125 +++++ .../ActionScheduler_WPCLI_QueueRunner.php | 2 +- ...ctionScheduler_WPCLI_Scheduler_command.php | 50 +- .../classes/abstracts/ActionScheduler.php | 41 +- .../ActionScheduler_Abstract_ListTable.php | 20 +- .../ActionScheduler_Abstract_QueueRunner.php | 117 ++++- .../ActionScheduler_Abstract_Schema.php | 31 +- .../abstracts/ActionScheduler_Lock.php | 2 + .../abstracts/ActionScheduler_Store.php | 4 +- .../actions/ActionScheduler_Action.php | 39 ++ .../data-stores/ActionScheduler_DBLogger.php | 2 +- .../data-stores/ActionScheduler_DBStore.php | 159 +++++-- .../ActionScheduler_wpPostStore.php | 15 +- .../classes/migration/Runner.php | 2 +- .../schema/ActionScheduler_StoreSchema.php | 8 +- .../ActionScheduler/functions.php | 107 ++++- inc/Dependencies/ActionScheduler/readme.txt | 66 ++- .../classes/wp-async-request.php | 57 ++- .../classes/wp-background-process.php | 428 ++++++++++++++---- 27 files changed, 1443 insertions(+), 292 deletions(-) create mode 100644 inc/Dependencies/ActionScheduler/classes/WP_CLI/ActionScheduler_WPCLI_Clean_Command.php diff --git a/inc/Dependencies/ActionScheduler/action-scheduler.php b/inc/Dependencies/ActionScheduler/action-scheduler.php index b950a70b2..cfa1f1c4a 100644 --- a/inc/Dependencies/ActionScheduler/action-scheduler.php +++ b/inc/Dependencies/ActionScheduler/action-scheduler.php @@ -5,8 +5,11 @@ * Description: A robust scheduling library for use in WordPress plugins. * Author: Automattic * Author URI: https://automattic.com/ - * Version: 3.5.4 + * Version: 3.7.1 * License: GPLv3 + * Requires at least: 6.2 + * Tested up to: 6.4 + * Requires PHP: 5.6 * * Copyright 2019 Automattic, Inc. (https://automattic.com/contact/) * @@ -26,27 +29,29 @@ * @package ActionScheduler */ -if ( ! function_exists( 'action_scheduler_register_3_dot_5_dot_4' ) && function_exists( 'add_action' ) ) { // WRCS: DEFINED_VERSION. +if ( ! function_exists( 'action_scheduler_register_3_dot_7_dot_1' ) && function_exists( 'add_action' ) ) { // WRCS: DEFINED_VERSION. if ( ! class_exists( 'ActionScheduler_Versions', false ) ) { require_once __DIR__ . '/classes/ActionScheduler_Versions.php'; add_action( 'plugins_loaded', array( 'ActionScheduler_Versions', 'initialize_latest_version' ), 1, 0 ); } - add_action( 'plugins_loaded', 'action_scheduler_register_3_dot_5_dot_4', 0, 0 ); // WRCS: DEFINED_VERSION. + add_action( 'plugins_loaded', 'action_scheduler_register_3_dot_7_dot_1', 0, 0 ); // WRCS: DEFINED_VERSION. + // phpcs:disable Generic.Functions.OpeningFunctionBraceKernighanRitchie.ContentAfterBrace /** * Registers this version of Action Scheduler. */ - function action_scheduler_register_3_dot_5_dot_4() { // WRCS: DEFINED_VERSION. + function action_scheduler_register_3_dot_7_dot_1() { // WRCS: DEFINED_VERSION. $versions = ActionScheduler_Versions::instance(); - $versions->register( '3.5.4', 'action_scheduler_initialize_3_dot_5_dot_4' ); // WRCS: DEFINED_VERSION. + $versions->register( '3.7.1', 'action_scheduler_initialize_3_dot_7_dot_1' ); // WRCS: DEFINED_VERSION. } + // phpcs:disable Generic.Functions.OpeningFunctionBraceKernighanRitchie.ContentAfterBrace /** * Initializes this version of Action Scheduler. */ - function action_scheduler_initialize_3_dot_5_dot_4() { // WRCS: DEFINED_VERSION. + function action_scheduler_initialize_3_dot_7_dot_1() { // WRCS: DEFINED_VERSION. // A final safety check is required even here, because historic versions of Action Scheduler // followed a different pattern (in some unusual cases, we could reach this point and the // ActionScheduler class is already defined—so we need to guard against that). @@ -58,7 +63,7 @@ function action_scheduler_initialize_3_dot_5_dot_4() { // WRCS: DEFINED_VERSION. // Support usage in themes - load this version if no plugin has loaded a version yet. if ( did_action( 'plugins_loaded' ) && ! doing_action( 'plugins_loaded' ) && ! class_exists( 'ActionScheduler', false ) ) { - action_scheduler_initialize_3_dot_5_dot_4(); // WRCS: DEFINED_VERSION. + action_scheduler_initialize_3_dot_7_dot_1(); // WRCS: DEFINED_VERSION. do_action( 'action_scheduler_pre_theme_init' ); ActionScheduler_Versions::initialize_latest_version(); } diff --git a/inc/Dependencies/ActionScheduler/changelog.txt b/inc/Dependencies/ActionScheduler/changelog.txt index 69aef1ff7..368a94f61 100644 --- a/inc/Dependencies/ActionScheduler/changelog.txt +++ b/inc/Dependencies/ActionScheduler/changelog.txt @@ -1,5 +1,65 @@ *** Changelog *** += 3.7.1 - 2023-12-13 = +* Release/3.7.0. +* Tweak - WP 6.4 compatibility. +* update semver to 5.7.2 because of a security vulnerability in 5.7.1. + += 3.7.0 - 2023-11-20 = +* Important: starting with this release, Action Scheduler follows an L-2 version policy (WordPress, and consequently PHP). +* Add extended indexes for hook_status_scheduled_date_gmt and status_sheduled_date_gmt. +* Catch and log exceptions thrown when actions can't be created, e.g. under a corrupt database schema. +* Release/3.6.4. +* Tweak - WP 6.4 compatibility. +* Update unit tests for upcoming dependency version policy. +* make sure hook action_scheduler_failed_execution can access original exception object. +* mention dependency version policy in usage.md. + += 3.6.4 - 2023-10-11 = +* Performance improvements when bulk cancelling actions. +* Dev-related fixes. + += 3.6.3 - 2023-09-13 = +* Use `_doing_it_wrong` in initialization check. + += 3.6.2 - 2023-08-09 = +* Add guidance about passing arguments. +* Atomic option locking. +* Improve bulk delete handling. +* Include database error in the exception message. +* Tweak - WP 6.3 compatibility. + += 3.6.1 - 2023-06-14 = +* Document new optional `$priority` arg for various API functions. +* Document the new `--exclude-groups` WP CLI option. +* Document the new `action_scheduler_init` hook. +* Ensure actions within each claim are executed in the expected order. +* Fix incorrect text domain. +* Remove SHOW TABLES usage when checking if tables exist. + += 3.6.0 - 2023-05-10 = +* Add $unique parameter to function signatures. +* Add a cast-to-int for extra safety before forming new DateTime object. +* Add a hook allowing exceptions for consistently failing recurring actions. +* Add action priorities. +* Add init hook. +* Always raise the time limit. +* Bump minimatch from 3.0.4 to 3.0.8. +* Bump yaml from 2.2.1 to 2.2.2. +* Defensive coding relating to gaps in declared schedule types. +* Do not process an action if it cannot be set to `in-progress`. +* Filter view labels (status names) should be translatable | #919. +* Fix WPCLI progress messages. +* Improve data-store initialization flow. +* Improve error handling across all supported PHP versions. +* Improve logic for flushing the runtime cache. +* Support exclusion of multiple groups. +* Update lint-staged and Node/NPM requirements. +* add CLI clean command. +* add CLI exclude-group filter. +* exclude past-due from list table all filter count. +* throwing an exception if as_schedule_recurring_action interval param is not of type integer. + = 3.5.4 - 2023-01-17 = * Add pre filters during action registration. * Async scheduling. diff --git a/inc/Dependencies/ActionScheduler/classes/ActionScheduler_ActionFactory.php b/inc/Dependencies/ActionScheduler/classes/ActionScheduler_ActionFactory.php index 8e2e65018..07bf01b35 100644 --- a/inc/Dependencies/ActionScheduler/classes/ActionScheduler_ActionFactory.php +++ b/inc/Dependencies/ActionScheduler/classes/ActionScheduler_ActionFactory.php @@ -13,10 +13,15 @@ class ActionScheduler_ActionFactory { * @param array $args Args to pass to callbacks when the hook is triggered. * @param ActionScheduler_Schedule $schedule The action's schedule. * @param string $group A group to put the action in. + * phpcs:ignore Squiz.Commenting.FunctionComment.ExtraParamComment + * @param int $priority The action priority. * * @return ActionScheduler_Action An instance of the stored action. */ public function get_stored_action( $status, $hook, array $args = array(), ActionScheduler_Schedule $schedule = null, $group = '' ) { + // The 6th parameter ($priority) is not formally declared in the method signature to maintain compatibility with + // third-party subclasses created before this param was added. + $priority = func_num_args() >= 6 ? (int) func_get_arg( 5 ) : 10; switch ( $status ) { case ActionScheduler_Store::STATUS_PENDING: @@ -36,17 +41,19 @@ public function get_stored_action( $status, $hook, array $args = array(), Action $action_class = apply_filters( 'action_scheduler_stored_action_class', $action_class, $status, $hook, $args, $schedule, $group ); $action = new $action_class( $hook, $args, $schedule, $group ); + $action->set_priority( $priority ); /** * Allow 3rd party code to change the instantiated action for a given hook, args, schedule and group. * - * @param ActionScheduler_Action $action The instantiated action. - * @param string $hook The instantiated action's hook. - * @param array $args The instantiated action's args. + * @param ActionScheduler_Action $action The instantiated action. + * @param string $hook The instantiated action's hook. + * @param array $args The instantiated action's args. * @param ActionScheduler_Schedule $schedule The instantiated action's schedule. - * @param string $group The instantiated action's group. + * @param string $group The instantiated action's group. + * @param int $priority The action priority. */ - return apply_filters( 'action_scheduler_stored_action_instance', $action, $hook, $args, $schedule, $group ); + return apply_filters( 'action_scheduler_stored_action_instance', $action, $hook, $args, $schedule, $group, $priority ); } /** @@ -229,9 +236,100 @@ public function repeat( $action ) { $schedule_class = get_class( $schedule ); $new_schedule = new $schedule( $next, $schedule->get_recurrence(), $schedule->get_first_date() ); $new_action = new ActionScheduler_Action( $action->get_hook(), $action->get_args(), $new_schedule, $action->get_group() ); + $new_action->set_priority( $action->get_priority() ); return $this->store( $new_action ); } + /** + * Creates a scheduled action. + * + * This general purpose method can be used in place of specific methods such as async(), + * async_unique(), single() or single_unique(), etc. + * + * @internal Not intended for public use, should not be overriden by subclasses. + * + * @param array $options { + * Describes the action we wish to schedule. + * + * @type string $type Must be one of 'async', 'cron', 'recurring', or 'single'. + * @type string $hook The hook to be executed. + * @type array $arguments Arguments to be passed to the callback. + * @type string $group The action group. + * @type bool $unique If the action should be unique. + * @type int $when Timestamp. Indicates when the action, or first instance of the action in the case + * of recurring or cron actions, becomes due. + * @type int|string $pattern Recurrence pattern. This is either an interval in seconds for recurring actions + * or a cron expression for cron actions. + * @type int $priority Lower values means higher priority. Should be in the range 0-255. + * } + * + * @return int The action ID. Zero if there was an error scheduling the action. + */ + public function create( array $options = array() ) { + $defaults = array( + 'type' => 'single', + 'hook' => '', + 'arguments' => array(), + 'group' => '', + 'unique' => false, + 'when' => time(), + 'pattern' => null, + 'priority' => 10, + ); + + $options = array_merge( $defaults, $options ); + + // Cron/recurring actions without a pattern are treated as single actions (this gives calling code the ability + // to use functions like as_schedule_recurring_action() to schedule recurring as well as single actions). + if ( ( 'cron' === $options['type'] || 'recurring' === $options['type'] ) && empty( $options['pattern'] ) ) { + $options['type'] = 'single'; + } + + switch ( $options['type'] ) { + case 'async': + $schedule = new ActionScheduler_NullSchedule(); + break; + + case 'cron': + $date = as_get_datetime_object( $options['when'] ); + $cron = CronExpression::factory( $options['pattern'] ); + $schedule = new ActionScheduler_CronSchedule( $date, $cron ); + break; + + case 'recurring': + $date = as_get_datetime_object( $options['when'] ); + $schedule = new ActionScheduler_IntervalSchedule( $date, $options['pattern'] ); + break; + + case 'single': + $date = as_get_datetime_object( $options['when'] ); + $schedule = new ActionScheduler_SimpleSchedule( $date ); + break; + + default: + error_log( "Unknown action type '{$options['type']}' specified when trying to create an action for '{$options['hook']}'." ); + return 0; + } + + $action = new ActionScheduler_Action( $options['hook'], $options['arguments'], $schedule, $options['group'] ); + $action->set_priority( $options['priority'] ); + + $action_id = 0; + try { + $action_id = $options['unique'] ? $this->store_unique_action( $action ) : $this->store( $action ); + } catch ( Exception $e ) { + error_log( + sprintf( + /* translators: %1$s is the name of the hook to be enqueued, %2$s is the exception message. */ + __( 'Caught exception while enqueuing action "%1$s": %2$s', 'action-scheduler' ), + $options['hook'], + $e->getMessage() + ) + ); + } + return $action_id; + } + /** * Save action to database. * diff --git a/inc/Dependencies/ActionScheduler/classes/ActionScheduler_Compatibility.php b/inc/Dependencies/ActionScheduler/classes/ActionScheduler_Compatibility.php index 85e0ed9da..bb28023bc 100644 --- a/inc/Dependencies/ActionScheduler/classes/ActionScheduler_Compatibility.php +++ b/inc/Dependencies/ActionScheduler/classes/ActionScheduler_Compatibility.php @@ -4,7 +4,6 @@ * Class ActionScheduler_Compatibility */ class ActionScheduler_Compatibility { - /** * Converts a shorthand byte value to an integer byte value. * @@ -89,21 +88,18 @@ public static function raise_time_limit( $limit = 0 ) { $limit = (int) $limit; $max_execution_time = (int) ini_get( 'max_execution_time' ); - /* - * If the max execution time is already unlimited (zero), or if it exceeds or is equal to the proposed - * limit, there is no reason for us to make further changes (we never want to lower it). - */ - if ( - 0 === $max_execution_time - || ( $max_execution_time >= $limit && $limit !== 0 ) - ) { + // If the max execution time is already set to zero (unlimited), there is no reason to make a further change. + if ( 0 === $max_execution_time ) { return; } + // Whichever of $max_execution_time or $limit is higher is the amount by which we raise the time limit. + $raise_by = 0 === $limit || $limit > $max_execution_time ? $limit : $max_execution_time; + if ( function_exists( 'wc_set_time_limit' ) ) { - wc_set_time_limit( $limit ); + wc_set_time_limit( $raise_by ); } elseif ( function_exists( 'set_time_limit' ) && false === strpos( ini_get( 'disable_functions' ), 'set_time_limit' ) && ! ini_get( 'safe_mode' ) ) { // phpcs:ignore PHPCompatibility.IniDirectives.RemovedIniDirectives.safe_modeDeprecatedRemoved - @set_time_limit( $limit ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + @set_time_limit( $raise_by ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged } } } diff --git a/inc/Dependencies/ActionScheduler/classes/ActionScheduler_ListTable.php b/inc/Dependencies/ActionScheduler/classes/ActionScheduler_ListTable.php index 9e631f754..a21fdbe37 100644 --- a/inc/Dependencies/ActionScheduler/classes/ActionScheduler_ListTable.php +++ b/inc/Dependencies/ActionScheduler/classes/ActionScheduler_ListTable.php @@ -252,7 +252,7 @@ private static function human_interval( $interval, $periods_to_include = 2 ) { */ protected function get_recurrence( $action ) { $schedule = $action->get_schedule(); - if ( $schedule->is_recurring() ) { + if ( $schedule->is_recurring() && method_exists( $schedule, 'get_recurrence' ) ) { $recurrence = $schedule->get_recurrence(); if ( is_numeric( $recurrence ) ) { @@ -471,7 +471,7 @@ protected function get_schedule_display_string( ActionScheduler_Schedule $schedu return __( 'async', 'action-scheduler' ); } - if ( ! $schedule->get_date() ) { + if ( ! method_exists( $schedule, 'get_date' ) || ! $schedule->get_date() ) { return '0000-00-00 00:00:00'; } @@ -502,7 +502,20 @@ protected function get_schedule_display_string( ActionScheduler_Schedule $schedu */ protected function bulk_delete( array $ids, $ids_sql ) { foreach ( $ids as $id ) { - $this->store->delete_action( $id ); + try { + $this->store->delete_action( $id ); + } catch ( Exception $e ) { + // A possible reason for an exception would include a scenario where the same action is deleted by a + // concurrent request. + error_log( + sprintf( + /* translators: 1: action ID 2: exception message. */ + __( 'Action Scheduler was unable to delete action %1$d. Reason: %2$s', 'action-scheduler' ), + $id, + $e->getMessage() + ) + ); + } } } diff --git a/inc/Dependencies/ActionScheduler/classes/ActionScheduler_OptionLock.php b/inc/Dependencies/ActionScheduler/classes/ActionScheduler_OptionLock.php index 4bc9a3fc2..911f9b77c 100644 --- a/inc/Dependencies/ActionScheduler/classes/ActionScheduler_OptionLock.php +++ b/inc/Dependencies/ActionScheduler/classes/ActionScheduler_OptionLock.php @@ -24,7 +24,37 @@ class ActionScheduler_OptionLock extends ActionScheduler_Lock { * @bool True if lock value has changed, false if not or if set failed. */ public function set( $lock_type ) { - return update_option( $this->get_key( $lock_type ), time() + $this->get_duration( $lock_type ) ); + global $wpdb; + + $lock_key = $this->get_key( $lock_type ); + $existing_lock_value = $this->get_existing_lock( $lock_type ); + $new_lock_value = $this->new_lock_value( $lock_type ); + + // The lock may not exist yet, or may have been deleted. + if ( empty( $existing_lock_value ) ) { + return (bool) $wpdb->insert( + $wpdb->options, + array( + 'option_name' => $lock_key, + 'option_value' => $new_lock_value, + 'autoload' => 'no', + ) + ); + } + + if ( $this->get_expiration_from( $existing_lock_value ) >= time() ) { + return false; + } + + // Otherwise, try to obtain the lock. + return (bool) $wpdb->update( + $wpdb->options, + array( 'option_value' => $new_lock_value ), + array( + 'option_name' => $lock_key, + 'option_value' => $existing_lock_value, + ) + ); } /** @@ -34,7 +64,30 @@ public function set( $lock_type ) { * @return bool|int False if no lock is set, otherwise the timestamp for when the lock is set to expire. */ public function get_expiration( $lock_type ) { - return get_option( $this->get_key( $lock_type ) ); + return $this->get_expiration_from( $this->get_existing_lock( $lock_type ) ); + } + + /** + * Given the lock string, derives the lock expiration timestamp (or false if it cannot be determined). + * + * @param string $lock_value String containing a timestamp, or pipe-separated combination of unique value and timestamp. + * + * @return false|int + */ + private function get_expiration_from( $lock_value ) { + $lock_string = explode( '|', $lock_value ); + + // Old style lock? + if ( count( $lock_string ) === 1 && is_numeric( $lock_string[0] ) ) { + return (int) $lock_string[0]; + } + + // New style lock? + if ( count( $lock_string ) === 2 && is_numeric( $lock_string[1] ) ) { + return (int) $lock_string[1]; + } + + return false; } /** @@ -46,4 +99,37 @@ public function get_expiration( $lock_type ) { protected function get_key( $lock_type ) { return sprintf( 'action_scheduler_lock_%s', $lock_type ); } + + /** + * Supplies the existing lock value, or an empty string if not set. + * + * @param string $lock_type A string to identify different lock types. + * + * @return string + */ + private function get_existing_lock( $lock_type ) { + global $wpdb; + + // Now grab the existing lock value, if there is one. + return (string) $wpdb->get_var( + $wpdb->prepare( + "SELECT option_value FROM $wpdb->options WHERE option_name = %s", + $this->get_key( $lock_type ) + ) + ); + } + + /** + * Supplies a lock value consisting of a unique value and the current timestamp, which are separated by a pipe + * character. + * + * Example: (string) "649de012e6b262.09774912|1688068114" + * + * @param string $lock_type A string to identify different lock types. + * + * @return string + */ + private function new_lock_value( $lock_type ) { + return uniqid( '', true ) . '|' . ( time() + $this->get_duration( $lock_type ) ); + } } diff --git a/inc/Dependencies/ActionScheduler/classes/ActionScheduler_QueueCleaner.php b/inc/Dependencies/ActionScheduler/classes/ActionScheduler_QueueCleaner.php index 49cd44bb2..6f2a696d2 100644 --- a/inc/Dependencies/ActionScheduler/classes/ActionScheduler_QueueCleaner.php +++ b/inc/Dependencies/ActionScheduler/classes/ActionScheduler_QueueCleaner.php @@ -18,6 +18,14 @@ class ActionScheduler_QueueCleaner { */ private $month_in_seconds = 2678400; + /** + * @var string[] Default list of statuses purged by the cleaner process. + */ + private $default_statuses_to_purge = [ + ActionScheduler_Store::STATUS_COMPLETE, + ActionScheduler_Store::STATUS_CANCELED, + ]; + /** * ActionScheduler_QueueCleaner constructor. * @@ -29,46 +37,113 @@ public function __construct( ActionScheduler_Store $store = null, $batch_size = $this->batch_size = $batch_size; } + /** + * Default queue cleaner process used by queue runner. + * + * @return array + */ public function delete_old_actions() { + /** + * Filter the minimum scheduled date age for action deletion. + * + * @param int $retention_period Minimum scheduled age in seconds of the actions to be deleted. + */ $lifespan = apply_filters( 'action_scheduler_retention_period', $this->month_in_seconds ); - $cutoff = as_get_datetime_object($lifespan.' seconds ago'); - $statuses_to_purge = array( - ActionScheduler_Store::STATUS_COMPLETE, - ActionScheduler_Store::STATUS_CANCELED, - ); + try { + $cutoff = as_get_datetime_object( $lifespan . ' seconds ago' ); + } catch ( Exception $e ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* Translators: %s is the exception message. */ + esc_html__( 'It was not possible to determine a valid cut-off time: %s.', 'action-scheduler' ), + esc_html( $e->getMessage() ) + ), + '3.5.5' + ); + + return array(); + } + + + /** + * Filter the statuses when cleaning the queue. + * + * @param string[] $default_statuses_to_purge Action statuses to clean. + */ + $statuses_to_purge = (array) apply_filters( 'action_scheduler_default_cleaner_statuses', $this->default_statuses_to_purge ); + + return $this->clean_actions( $statuses_to_purge, $cutoff, $this->get_batch_size() ); + } + + /** + * Delete selected actions limited by status and date. + * + * @param string[] $statuses_to_purge List of action statuses to purge. Defaults to canceled, complete. + * @param DateTime $cutoff_date Date limit for selecting actions. Defaults to 31 days ago. + * @param int|null $batch_size Maximum number of actions per status to delete. Defaults to 20. + * @param string $context Calling process context. Defaults to `old`. + * @return array Actions deleted. + */ + public function clean_actions( array $statuses_to_purge, DateTime $cutoff_date, $batch_size = null, $context = 'old' ) { + $batch_size = $batch_size !== null ? $batch_size : $this->batch_size; + $cutoff = $cutoff_date !== null ? $cutoff_date : as_get_datetime_object( $this->month_in_seconds . ' seconds ago' ); + $lifespan = time() - $cutoff->getTimestamp(); + if ( empty( $statuses_to_purge ) ) { + $statuses_to_purge = $this->default_statuses_to_purge; + } + $deleted_actions = []; foreach ( $statuses_to_purge as $status ) { $actions_to_delete = $this->store->query_actions( array( 'status' => $status, 'modified' => $cutoff, 'modified_compare' => '<=', - 'per_page' => $this->get_batch_size(), + 'per_page' => $batch_size, 'orderby' => 'none', ) ); - foreach ( $actions_to_delete as $action_id ) { - try { - $this->store->delete_action( $action_id ); - } catch ( Exception $e ) { - - /** - * Notify 3rd party code of exceptions when deleting a completed action older than the retention period - * - * This hook provides a way for 3rd party code to log or otherwise handle exceptions relating to their - * actions. - * - * @since 2.0.0 - * - * @param int $action_id The scheduled actions ID in the data store - * @param Exception $e The exception thrown when attempting to delete the action from the data store - * @param int $lifespan The retention period, in seconds, for old actions - * @param int $count_of_actions_to_delete The number of old actions being deleted in this batch - */ - do_action( 'action_scheduler_failed_old_action_deletion', $action_id, $e, $lifespan, count( $actions_to_delete ) ); - } + $deleted_actions = array_merge( $deleted_actions, $this->delete_actions( $actions_to_delete, $lifespan, $context ) ); + } + + return $deleted_actions; + } + + /** + * @param int[] $actions_to_delete List of action IDs to delete. + * @param int $lifespan Minimum scheduled age in seconds of the actions being deleted. + * @param string $context Context of the delete request. + * @return array Deleted action IDs. + */ + private function delete_actions( array $actions_to_delete, $lifespan = null, $context = 'old' ) { + $deleted_actions = []; + if ( $lifespan === null ) { + $lifespan = $this->month_in_seconds; + } + + foreach ( $actions_to_delete as $action_id ) { + try { + $this->store->delete_action( $action_id ); + $deleted_actions[] = $action_id; + } catch ( Exception $e ) { + /** + * Notify 3rd party code of exceptions when deleting a completed action older than the retention period + * + * This hook provides a way for 3rd party code to log or otherwise handle exceptions relating to their + * actions. + * + * @param int $action_id The scheduled actions ID in the data store + * @param Exception $e The exception thrown when attempting to delete the action from the data store + * @param int $lifespan The retention period, in seconds, for old actions + * @param int $count_of_actions_to_delete The number of old actions being deleted in this batch + * @since 2.0.0 + * + */ + do_action( "action_scheduler_failed_{$context}_action_deletion", $action_id, $e, $lifespan, count( $actions_to_delete ) ); } } + return $deleted_actions; } /** diff --git a/inc/Dependencies/ActionScheduler/classes/ActionScheduler_QueueRunner.php b/inc/Dependencies/ActionScheduler/classes/ActionScheduler_QueueRunner.php index b890dca13..1ec3eab2a 100644 --- a/inc/Dependencies/ActionScheduler/classes/ActionScheduler_QueueRunner.php +++ b/inc/Dependencies/ActionScheduler/classes/ActionScheduler_QueueRunner.php @@ -103,9 +103,12 @@ public function unhook_dispatch_async_request() { * should dispatch a request to process pending actions. */ public function maybe_dispatch_async_request() { - if ( is_admin() && ! ActionScheduler::lock()->is_locked( 'async-request-runner' ) ) { - // Only start an async queue at most once every 60 seconds - ActionScheduler::lock()->set( 'async-request-runner' ); + // Only start an async queue at most once every 60 seconds. + if ( + is_admin() + && ! ActionScheduler::lock()->is_locked( 'async-request-runner' ) + && ActionScheduler::lock()->set( 'async-request-runner' ) + ) { $this->async_request->maybe_dispatch(); } } @@ -185,9 +188,15 @@ protected function do_batch( $size = 100, $context = '' ) { protected function clear_caches() { /* * Calling wp_cache_flush_runtime() lets us clear the runtime cache without invalidating the external object - * cache, so we will always prefer this when it is available (but it was only introduced in WordPress 6.0). + * cache, so we will always prefer this method (as compared to calling wp_cache_flush()) when it is available. + * + * However, this function was only introduced in WordPress 6.0. Additionally, the preferred way of detecting if + * it is supported changed in WordPress 6.1 so we use two different methods to decide if we should utilize it. */ - if ( function_exists( 'wp_cache_flush_runtime' ) ) { + $flushing_runtime_cache_explicitly_supported = function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_runtime' ); + $flushing_runtime_cache_implicitly_supported = ! function_exists( 'wp_cache_supports' ) && function_exists( 'wp_cache_flush_runtime' ); + + if ( $flushing_runtime_cache_explicitly_supported || $flushing_runtime_cache_implicitly_supported ) { wp_cache_flush_runtime(); } elseif ( ! wp_using_ext_object_cache() diff --git a/inc/Dependencies/ActionScheduler/classes/WP_CLI/ActionScheduler_WPCLI_Clean_Command.php b/inc/Dependencies/ActionScheduler/classes/WP_CLI/ActionScheduler_WPCLI_Clean_Command.php new file mode 100644 index 000000000..ff6e57aa3 --- /dev/null +++ b/inc/Dependencies/ActionScheduler/classes/WP_CLI/ActionScheduler_WPCLI_Clean_Command.php @@ -0,0 +1,125 @@ +] + * : The maximum number of actions to delete per batch. Defaults to 20. + * + * [--batches=] + * : Limit execution to a number of batches. Defaults to 0, meaning batches will continue all eligible actions are deleted. + * + * [--status=] + * : Only clean actions with the specified status. Defaults to Canceled, Completed. Define multiple statuses as a comma separated string (without spaces), e.g. `--status=complete,failed,canceled` + * + * [--before=] + * : Only delete actions with scheduled date older than this. Defaults to 31 days. e.g `--before='7 days ago'`, `--before='02-Feb-2020 20:20:20'` + * + * [--pause=] + * : The number of seconds to pause between batches. Default no pause. + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @throws \WP_CLI\ExitException When an error occurs. + * + * @subcommand clean + */ + public function clean( $args, $assoc_args ) { + // Handle passed arguments. + $batch = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batch-size', 20 ) ); + $batches = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batches', 0 ) ); + $status = explode( ',', WP_CLI\Utils\get_flag_value( $assoc_args, 'status', '' ) ); + $status = array_filter( array_map( 'trim', $status ) ); + $before = \WP_CLI\Utils\get_flag_value( $assoc_args, 'before', '' ); + $sleep = \WP_CLI\Utils\get_flag_value( $assoc_args, 'pause', 0 ); + + $batches_completed = 0; + $actions_deleted = 0; + $unlimited = $batches === 0; + try { + $lifespan = as_get_datetime_object( $before ); + } catch ( Exception $e ) { + $lifespan = null; + } + + try { + // Custom queue cleaner instance. + $cleaner = new ActionScheduler_QueueCleaner( null, $batch ); + + // Clean actions for as long as possible. + while ( $unlimited || $batches_completed < $batches ) { + if ( $sleep && $batches_completed > 0 ) { + sleep( $sleep ); + } + + $deleted = count( $cleaner->clean_actions( $status, $lifespan, null,'CLI' ) ); + if ( $deleted <= 0 ) { + break; + } + $actions_deleted += $deleted; + $batches_completed++; + $this->print_success( $deleted ); + } + } catch ( Exception $e ) { + $this->print_error( $e ); + } + + $this->print_total_batches( $batches_completed ); + if ( $batches_completed > 1 ) { + $this->print_success( $actions_deleted ); + } + } + + /** + * Print WP CLI message about how many batches of actions were processed. + * + * @param int $batches_processed + */ + protected function print_total_batches( int $batches_processed ) { + WP_CLI::log( + sprintf( + /* translators: %d refers to the total number of batches processed */ + _n( '%d batch processed.', '%d batches processed.', $batches_processed, 'action-scheduler' ), + $batches_processed + ) + ); + } + + /** + * Convert an exception into a WP CLI error. + * + * @param Exception $e The error object. + * + * @throws \WP_CLI\ExitException + */ + protected function print_error( Exception $e ) { + WP_CLI::error( + sprintf( + /* translators: %s refers to the exception error message */ + __( 'There was an error deleting an action: %s', 'action-scheduler' ), + $e->getMessage() + ) + ); + } + + /** + * Print a success message with the number of completed actions. + * + * @param int $actions_deleted + */ + protected function print_success( int $actions_deleted ) { + WP_CLI::success( + sprintf( + /* translators: %d refers to the total number of actions deleted */ + _n( '%d action deleted.', '%d actions deleted.', $actions_deleted, 'action-scheduler' ), + $actions_deleted + ) + ); + } +} diff --git a/inc/Dependencies/ActionScheduler/classes/WP_CLI/ActionScheduler_WPCLI_QueueRunner.php b/inc/Dependencies/ActionScheduler/classes/WP_CLI/ActionScheduler_WPCLI_QueueRunner.php index c33de6867..4681daa49 100644 --- a/inc/Dependencies/ActionScheduler/classes/WP_CLI/ActionScheduler_WPCLI_QueueRunner.php +++ b/inc/Dependencies/ActionScheduler/classes/WP_CLI/ActionScheduler_WPCLI_QueueRunner.php @@ -90,7 +90,7 @@ protected function setup_progress_bar() { $count = count( $this->actions ); $this->progress_bar = new ProgressBar( /* translators: %d: amount of actions */ - sprintf( _n( 'Running %d action', 'Running %d actions', $count, 'action-scheduler' ), number_format_i18n( $count ) ), + sprintf( _n( 'Running %d action', 'Running %d actions', $count, 'action-scheduler' ), $count ), $count ); } diff --git a/inc/Dependencies/ActionScheduler/classes/WP_CLI/ActionScheduler_WPCLI_Scheduler_command.php b/inc/Dependencies/ActionScheduler/classes/WP_CLI/ActionScheduler_WPCLI_Scheduler_command.php index 70b052e58..2c68a3860 100644 --- a/inc/Dependencies/ActionScheduler/classes/WP_CLI/ActionScheduler_WPCLI_Scheduler_command.php +++ b/inc/Dependencies/ActionScheduler/classes/WP_CLI/ActionScheduler_WPCLI_Scheduler_command.php @@ -55,6 +55,9 @@ public function fix_schema( $args, $assoc_args ) { * [--group=] * : Only run actions from the specified group. Omitting this option runs actions from all groups. * + * [--exclude-groups=] + * : Run actions from all groups except the specified group(s). Define multiple groups as a comma separated string (without spaces), e.g. '--group_a,group_b'. This option is ignored when `--group` is used. + * * [--free-memory-on=] * : The number of actions to process between freeing memory. 0 disables freeing memory. Default 50. * @@ -72,15 +75,16 @@ public function fix_schema( $args, $assoc_args ) { */ public function run( $args, $assoc_args ) { // Handle passed arguments. - $batch = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batch-size', 100 ) ); - $batches = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batches', 0 ) ); - $clean = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'cleanup-batch-size', $batch ) ); - $hooks = explode( ',', WP_CLI\Utils\get_flag_value( $assoc_args, 'hooks', '' ) ); - $hooks = array_filter( array_map( 'trim', $hooks ) ); - $group = \WP_CLI\Utils\get_flag_value( $assoc_args, 'group', '' ); - $free_on = \WP_CLI\Utils\get_flag_value( $assoc_args, 'free-memory-on', 50 ); - $sleep = \WP_CLI\Utils\get_flag_value( $assoc_args, 'pause', 0 ); - $force = \WP_CLI\Utils\get_flag_value( $assoc_args, 'force', false ); + $batch = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batch-size', 100 ) ); + $batches = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batches', 0 ) ); + $clean = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'cleanup-batch-size', $batch ) ); + $hooks = explode( ',', WP_CLI\Utils\get_flag_value( $assoc_args, 'hooks', '' ) ); + $hooks = array_filter( array_map( 'trim', $hooks ) ); + $group = \WP_CLI\Utils\get_flag_value( $assoc_args, 'group', '' ); + $exclude_groups = \WP_CLI\Utils\get_flag_value( $assoc_args, 'exclude-groups', '' ); + $free_on = \WP_CLI\Utils\get_flag_value( $assoc_args, 'free-memory-on', 50 ); + $sleep = \WP_CLI\Utils\get_flag_value( $assoc_args, 'pause', 0 ); + $force = \WP_CLI\Utils\get_flag_value( $assoc_args, 'force', false ); ActionScheduler_DataController::set_free_ticks( $free_on ); ActionScheduler_DataController::set_sleep_time( $sleep ); @@ -88,6 +92,13 @@ public function run( $args, $assoc_args ) { $batches_completed = 0; $actions_completed = 0; $unlimited = $batches === 0; + if ( is_callable( [ ActionScheduler::store(), 'set_claim_filter' ] ) ) { + $exclude_groups = $this->parse_comma_separated_string( $exclude_groups ); + + if ( ! empty( $exclude_groups ) ) { + ActionScheduler::store()->set_claim_filter('exclude-groups', $exclude_groups ); + } + } try { // Custom queue cleaner instance. @@ -116,6 +127,17 @@ public function run( $args, $assoc_args ) { $this->print_success( $actions_completed ); } + /** + * Converts a string of comma-separated values into an array of those same values. + * + * @param string $string The string of one or more comma separated values. + * + * @return array + */ + private function parse_comma_separated_string( $string ): array { + return array_filter( str_getcsv( $string ) ); + } + /** * Print WP CLI message about how many actions are about to be processed. * @@ -126,9 +148,9 @@ public function run( $args, $assoc_args ) { protected function print_total_actions( $total ) { WP_CLI::log( sprintf( - /* translators: %d refers to how many scheduled taks were found to run */ + /* translators: %d refers to how many scheduled tasks were found to run */ _n( 'Found %d scheduled task', 'Found %d scheduled tasks', $total, 'action-scheduler' ), - number_format_i18n( $total ) + $total ) ); } @@ -145,7 +167,7 @@ protected function print_total_batches( $batches_completed ) { sprintf( /* translators: %d refers to the total number of batches executed */ _n( '%d batch executed.', '%d batches executed.', $batches_completed, 'action-scheduler' ), - number_format_i18n( $batches_completed ) + $batches_completed ) ); } @@ -179,9 +201,9 @@ protected function print_error( Exception $e ) { protected function print_success( $actions_completed ) { WP_CLI::success( sprintf( - /* translators: %d refers to the total number of taskes completed */ + /* translators: %d refers to the total number of tasks completed */ _n( '%d scheduled task completed.', '%d scheduled tasks completed.', $actions_completed, 'action-scheduler' ), - number_format_i18n( $actions_completed ) + $actions_completed ) ); } diff --git a/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler.php b/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler.php index e8873f11e..0163f7072 100644 --- a/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler.php +++ b/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler.php @@ -153,11 +153,41 @@ public static function init( $plugin_file ) { add_action( 'init', array( $store, 'init' ), 1, 0 ); add_action( 'init', array( $logger, 'init' ), 1, 0 ); add_action( 'init', array( $runner, 'init' ), 1, 0 ); + + add_action( + 'init', + /** + * Runs after the active store's init() method has been called. + * + * It would probably be preferable to have $store->init() (or it's parent method) set this itself, + * once it has initialized, however that would cause problems in cases where a custom data store is in + * use and it has not yet been updated to follow that same logic. + */ + function () { + self::$data_store_initialized = true; + + /** + * Fires when Action Scheduler is ready: it is safe to use the procedural API after this point. + * + * @since 3.5.5 + */ + do_action( 'action_scheduler_init' ); + }, + 1 + ); } else { $admin_view->init(); $store->init(); $logger->init(); $runner->init(); + self::$data_store_initialized = true; + + /** + * Fires when Action Scheduler is ready: it is safe to use the procedural API after this point. + * + * @since 3.5.5 + */ + do_action( 'action_scheduler_init' ); } if ( apply_filters( 'action_scheduler_load_deprecated_functions', true ) ) { @@ -166,14 +196,13 @@ public static function init( $plugin_file ) { if ( defined( 'WP_CLI' ) && WP_CLI ) { WP_CLI::add_command( 'action-scheduler', 'ActionScheduler_WPCLI_Scheduler_command' ); + WP_CLI::add_command( 'action-scheduler', 'ActionScheduler_WPCLI_Clean_Command' ); if ( ! ActionScheduler_DataController::is_migration_complete() && Controller::instance()->allow_migration() ) { $command = new Migration_Command(); $command->register(); } } - self::$data_store_initialized = true; - /** * Handle WP comment cleanup after migration. */ @@ -192,8 +221,12 @@ public static function init( $plugin_file ) { */ public static function is_initialized( $function_name = null ) { if ( ! self::$data_store_initialized && ! empty( $function_name ) ) { - $message = sprintf( __( '%s() was called before the Action Scheduler data store was initialized', 'action-scheduler' ), esc_attr( $function_name ) ); - error_log( $message, E_WARNING ); + $message = sprintf( + /* translators: %s function name. */ + __( '%s() was called before the Action Scheduler data store was initialized', 'action-scheduler' ), + esc_attr( $function_name ) + ); + _doing_it_wrong( $function_name, $message, '3.1.6' ); } return self::$data_store_initialized; diff --git a/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Abstract_ListTable.php b/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Abstract_ListTable.php index ccc997f2f..8d1465fc1 100644 --- a/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Abstract_ListTable.php +++ b/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Abstract_ListTable.php @@ -673,24 +673,34 @@ protected function display_filter_by_status() { // Helper to set 'all' filter when not set on status counts passed in. if ( ! isset( $this->status_counts['all'] ) ) { - $this->status_counts = array( 'all' => array_sum( $this->status_counts ) ) + $this->status_counts; + $all_count = array_sum( $this->status_counts ); + if ( isset( $this->status_counts['past-due'] ) ) { + $all_count -= $this->status_counts['past-due']; + } + $this->status_counts = array( 'all' => $all_count ) + $this->status_counts; } - foreach ( $this->status_counts as $status_name => $count ) { + // Translated status labels. + $status_labels = ActionScheduler_Store::instance()->get_status_labels(); + $status_labels['all'] = _x( 'All', 'status labels', 'action-scheduler' ); + $status_labels['past-due'] = _x( 'Past-due', 'status labels', 'action-scheduler' ); + + foreach ( $this->status_counts as $status_slug => $count ) { if ( 0 === $count ) { continue; } - if ( $status_name === $request_status || ( empty( $request_status ) && 'all' === $status_name ) ) { + if ( $status_slug === $request_status || ( empty( $request_status ) && 'all' === $status_slug ) ) { $status_list_item = '
  • %3$s (%4$d)
  • '; } else { $status_list_item = '
  • %3$s (%4$d)
  • '; } - $status_filter_url = ( 'all' === $status_name ) ? remove_query_arg( 'status' ) : add_query_arg( 'status', $status_name ); + $status_name = isset( $status_labels[ $status_slug ] ) ? $status_labels[ $status_slug ] : ucfirst( $status_slug ); + $status_filter_url = ( 'all' === $status_slug ) ? remove_query_arg( 'status' ) : add_query_arg( 'status', $status_slug ); $status_filter_url = remove_query_arg( array( 'paged', 's' ), $status_filter_url ); - $status_list_items[] = sprintf( $status_list_item, esc_attr( $status_name ), esc_url( $status_filter_url ), esc_html( ucfirst( $status_name ) ), absint( $count ) ); + $status_list_items[] = sprintf( $status_list_item, esc_attr( $status_slug ), esc_url( $status_filter_url ), esc_html( $status_name ), absint( $count ) ); } if ( $status_list_items ) { diff --git a/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Abstract_QueueRunner.php b/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Abstract_QueueRunner.php index 3440f0016..673499fca 100644 --- a/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Abstract_QueueRunner.php +++ b/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Abstract_QueueRunner.php @@ -48,30 +48,56 @@ public function __construct( ActionScheduler_Store $store = null, ActionSchedule * Generally, this should be capitalised and not localised as it's a proper noun. */ public function process_action( $action_id, $context = '' ) { - try { - $valid_action = false; - do_action( 'action_scheduler_before_execute', $action_id, $context ); + // Temporarily override the error handler while we process the current action. + set_error_handler( + /** + * Temporary error handler which can catch errors and convert them into exceptions. This faciliates more + * robust error handling across all supported PHP versions. + * + * @throws Exception + * + * @param int $type Error level expressed as an integer. + * @param string $message Error message. + */ + function ( $type, $message ) { + throw new Exception( $message ); + }, + E_USER_ERROR | E_RECOVERABLE_ERROR + ); - if ( ActionScheduler_Store::STATUS_PENDING !== $this->store->get_status( $action_id ) ) { - do_action( 'action_scheduler_execution_ignored', $action_id, $context ); - return; + /* + * The nested try/catch structure is required because we potentially need to convert thrown errors into + * exceptions (and an exception thrown from a catch block cannot be caught by a later catch block in the *same* + * structure). + */ + try { + try { + $valid_action = false; + do_action( 'action_scheduler_before_execute', $action_id, $context ); + + if ( ActionScheduler_Store::STATUS_PENDING !== $this->store->get_status( $action_id ) ) { + do_action( 'action_scheduler_execution_ignored', $action_id, $context ); + return; + } + + $valid_action = true; + do_action( 'action_scheduler_begin_execute', $action_id, $context ); + + $action = $this->store->fetch_action( $action_id ); + $this->store->log_execution( $action_id ); + $action->execute(); + do_action( 'action_scheduler_after_execute', $action_id, $action, $context ); + $this->store->mark_complete( $action_id ); + } catch ( Throwable $e ) { + // Throwable is defined when executing under PHP 7.0 and up. We convert it to an exception, for + // compatibility with ActionScheduler_Logger. + throw new Exception( $e->getMessage(), $e->getCode(), $e ); } - - $valid_action = true; - do_action( 'action_scheduler_begin_execute', $action_id, $context ); - - $action = $this->store->fetch_action( $action_id ); - $this->store->log_execution( $action_id ); - $action->execute(); - do_action( 'action_scheduler_after_execute', $action_id, $action, $context ); - $this->store->mark_complete( $action_id ); } catch ( Exception $e ) { - if ( $valid_action ) { - $this->store->mark_failure( $action_id ); - do_action( 'action_scheduler_failed_execution', $action_id, $e, $context ); - } else { - do_action( 'action_scheduler_failed_validation', $action_id, $e, $context ); - } + // This catch block exists for compatibility with PHP 5.6. + $this->handle_action_error( $action_id, $e, $context, $valid_action ); + } finally { + restore_error_handler(); } if ( isset( $action ) && is_a( $action, 'ActionScheduler_Action' ) && $action->get_schedule()->is_recurring() ) { @@ -79,6 +105,39 @@ public function process_action( $action_id, $context = '' ) { } } + /** + * Marks actions as either having failed execution or failed validation, as appropriate. + * + * @param int $action_id Action ID. + * @param Exception $e Exception instance. + * @param string $context Execution context. + * @param bool $valid_action If the action is valid. + * + * @return void + */ + private function handle_action_error( $action_id, $e, $context, $valid_action ) { + if ( $valid_action ) { + $this->store->mark_failure( $action_id ); + /** + * Runs when action execution fails. + * + * @param int $action_id Action ID. + * @param Exception $e Exception instance. + * @param string $context Execution context. + */ + do_action( 'action_scheduler_failed_execution', $action_id, $e, $context ); + } else { + /** + * Runs when action validation fails. + * + * @param int $action_id Action ID. + * @param Exception $e Exception instance. + * @param string $context Execution context. + */ + do_action( 'action_scheduler_failed_validation', $action_id, $e, $context ); + } + } + /** * Schedule the next instance of the action if necessary. * @@ -143,12 +202,22 @@ private function recurring_action_is_consistently_failing( ActionScheduler_Actio return false; } - // Now let's fetch the first action (having the same hook) of *any status*ithin the same window. + // Now let's fetch the first action (having the same hook) of *any status* within the same window. unset( $query_args['status'] ); $first_action_id_with_the_same_hook = $this->store->query_actions( $query_args ); - // If the IDs match, then actions for this hook must be consistently failing. - return $first_action_id_with_the_same_hook === $first_failing_action_id; + /** + * If a recurring action is assessed as consistently failing, it will not be rescheduled. This hook provides a + * way to observe and optionally override that assessment. + * + * @param bool $is_consistently_failing If the action is considered to be consistently failing. + * @param ActionScheduler_Action $action The action being assessed. + */ + return (bool) apply_filters( + 'action_scheduler_recurring_action_is_consistently_failing', + $first_action_id_with_the_same_hook === $first_failing_action_id, + $action + ); } /** diff --git a/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Abstract_Schema.php b/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Abstract_Schema.php index 2334fda10..3fd259ea7 100644 --- a/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Abstract_Schema.php +++ b/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Abstract_Schema.php @@ -25,7 +25,7 @@ abstract class ActionScheduler_Abstract_Schema { /** * @var array Names of tables that will be registered by this class. */ - protected $tables = []; + protected $tables = array(); /** * Can optionally be used by concrete classes to carry out additional initialization work @@ -90,10 +90,10 @@ private function schema_update_required() { $plugin_option_name = 'schema-'; switch ( static::class ) { - case 'ActionScheduler_StoreSchema' : + case 'ActionScheduler_StoreSchema': $plugin_option_name .= 'Action_Scheduler\Custom_Tables\DB_Store_Table_Maker'; break; - case 'ActionScheduler_LoggerSchema' : + case 'ActionScheduler_LoggerSchema': $plugin_option_name .= 'Action_Scheduler\Custom_Tables\DB_Logger_Table_Maker'; break; } @@ -129,7 +129,7 @@ private function mark_schema_update_complete() { * @return void */ private function update_table( $table ) { - require_once( ABSPATH . 'wp-admin/includes/upgrade.php' ); + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $definition = $this->get_table_definition( $table ); if ( $definition ) { $updated = dbDelta( $definition ); @@ -148,7 +148,7 @@ private function update_table( $table ) { * table prefix for the current blog */ protected function get_full_table_name( $table ) { - return $GLOBALS[ 'wpdb' ]->prefix . $table; + return $GLOBALS['wpdb']->prefix . $table; } /** @@ -159,14 +159,19 @@ protected function get_full_table_name( $table ) { public function tables_exist() { global $wpdb; - $existing_tables = $wpdb->get_col( 'SHOW TABLES' ); - $expected_tables = array_map( - function ( $table_name ) use ( $wpdb ) { - return $wpdb->prefix . $table_name; - }, - $this->tables - ); + $tables_exist = true; - return count( array_intersect( $existing_tables, $expected_tables ) ) === count( $expected_tables ); + foreach ( $this->tables as $table_name ) { + $table_name = $wpdb->prefix . $table_name; + $pattern = str_replace( '_', '\\_', $table_name ); + $existing_table = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $pattern ) ); + + if ( $existing_table !== $table_name ) { + $tables_exist = false; + break; + } + } + + return $tables_exist; } } diff --git a/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Lock.php b/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Lock.php index 86e852851..e388a58fa 100644 --- a/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Lock.php +++ b/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Lock.php @@ -26,6 +26,8 @@ public function is_locked( $lock_type ) { /** * Set a lock. * + * To prevent race conditions, implementations should avoid setting the lock if the lock is already held. + * * @param string $lock_type A string to identify different lock types. * @return bool */ diff --git a/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Store.php b/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Store.php index a55529332..faaaa9ed3 100644 --- a/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Store.php +++ b/inc/Dependencies/ActionScheduler/classes/abstracts/ActionScheduler_Store.php @@ -347,7 +347,7 @@ public function cancel_actions_by_hook( $hook ) { 'hook' => $hook, 'status' => self::STATUS_PENDING, 'per_page' => 1000, - 'orderby' => 'action_id', + 'orderby' => 'none', ) ); @@ -372,7 +372,7 @@ public function cancel_actions_by_group( $group ) { 'group' => $group, 'status' => self::STATUS_PENDING, 'per_page' => 1000, - 'orderby' => 'action_id', + 'orderby' => 'none', ) ); diff --git a/inc/Dependencies/ActionScheduler/classes/actions/ActionScheduler_Action.php b/inc/Dependencies/ActionScheduler/classes/actions/ActionScheduler_Action.php index f538f506b..ddf33d5d9 100644 --- a/inc/Dependencies/ActionScheduler/classes/actions/ActionScheduler_Action.php +++ b/inc/Dependencies/ActionScheduler/classes/actions/ActionScheduler_Action.php @@ -10,6 +10,19 @@ class ActionScheduler_Action { protected $schedule = NULL; protected $group = ''; + /** + * Priorities are conceptually similar to those used for regular WordPress actions. + * Like those, a lower priority takes precedence over a higher priority and the default + * is 10. + * + * Unlike regular WordPress actions, the priority of a scheduled action is strictly an + * integer and should be kept within the bounds 0-255 (anything outside the bounds will + * be brought back into the acceptable range). + * + * @var int + */ + protected $priority = 10; + public function __construct( $hook, array $args = array(), ActionScheduler_Schedule $schedule = NULL, $group = '' ) { $schedule = empty( $schedule ) ? new ActionScheduler_NullSchedule() : $schedule; $this->set_hook($hook); @@ -93,4 +106,30 @@ public function get_group() { public function is_finished() { return FALSE; } + + /** + * Sets the priority of the action. + * + * @param int $priority Priority level (lower is higher priority). Should be in the range 0-255. + * + * @return void + */ + public function set_priority( $priority ) { + if ( $priority < 0 ) { + $priority = 0; + } elseif ( $priority > 255 ) { + $priority = 255; + } + + $this->priority = (int) $priority; + } + + /** + * Gets the action priority. + * + * @return int + */ + public function get_priority() { + return $this->priority; + } } diff --git a/inc/Dependencies/ActionScheduler/classes/data-stores/ActionScheduler_DBLogger.php b/inc/Dependencies/ActionScheduler/classes/data-stores/ActionScheduler_DBLogger.php index 37bfd0d44..d285c8d27 100644 --- a/inc/Dependencies/ActionScheduler/classes/data-stores/ActionScheduler_DBLogger.php +++ b/inc/Dependencies/ActionScheduler/classes/data-stores/ActionScheduler_DBLogger.php @@ -82,7 +82,7 @@ private function create_entry_from_db_record( $record ) { } /** - * Retrieve the an action's log entries from the database. + * Retrieve an action's log entries from the database. * * @param int $action_id Action ID. * diff --git a/inc/Dependencies/ActionScheduler/classes/data-stores/ActionScheduler_DBStore.php b/inc/Dependencies/ActionScheduler/classes/data-stores/ActionScheduler_DBStore.php index 5009454f7..602c3dd97 100644 --- a/inc/Dependencies/ActionScheduler/classes/data-stores/ActionScheduler_DBStore.php +++ b/inc/Dependencies/ActionScheduler/classes/data-stores/ActionScheduler_DBStore.php @@ -25,6 +25,13 @@ class ActionScheduler_DBStore extends ActionScheduler_Store { /** @var int */ protected static $max_index_length = 191; + /** @var array List of claim filters. */ + protected $claim_filters = [ + 'group' => '', + 'hooks' => '', + 'exclude-groups' => '', + ]; + /** * Initialize the data store * @@ -84,7 +91,8 @@ private function save_action_to_db( ActionScheduler_Action $action, DateTime $da 'scheduled_date_gmt' => $this->get_scheduled_date_string( $action, $date ), 'scheduled_date_local' => $this->get_scheduled_date_string_local( $action, $date ), 'schedule' => serialize( $action->get_schedule() ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize - 'group_id' => $this->get_group_id( $action->get_group() ), + 'group_id' => current( $this->get_group_ids( $action->get_group() ) ), + 'priority' => $action->get_priority(), ); $args = wp_json_encode( $action->get_args() ); @@ -172,6 +180,7 @@ private function build_where_clause_for_insert( $data, $table_name, $unique ) { ActionScheduler_Store::STATUS_RUNNING, ); $pending_status_placeholders = implode( ', ', array_fill( 0, count( $pending_statuses ), '%s' ) ); + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $pending_status_placeholders is hardcoded. $where_clause = $wpdb->prepare( " @@ -242,23 +251,35 @@ protected function get_args_for_query( $args ) { /** * Get a group's ID based on its name/slug. * - * @param string $slug The string name of a group. - * @param bool $create_if_not_exists Whether to create the group if it does not already exist. Default, true - create the group. + * @param string|array $slugs The string name of a group, or names for several groups. + * @param bool $create_if_not_exists Whether to create the group if it does not already exist. Default, true - create the group. * - * @return int The group's ID, if it exists or is created, or 0 if it does not exist and is not created. + * @return array The group IDs, if they exist or were successfully created. May be empty. */ - protected function get_group_id( $slug, $create_if_not_exists = true ) { - if ( empty( $slug ) ) { - return 0; + protected function get_group_ids( $slugs, $create_if_not_exists = true ) { + $slugs = (array) $slugs; + $group_ids = array(); + + if ( empty( $slugs ) ) { + return array(); } + /** @var \wpdb $wpdb */ global $wpdb; - $group_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT group_id FROM {$wpdb->actionscheduler_groups} WHERE slug=%s", $slug ) ); - if ( empty( $group_id ) && $create_if_not_exists ) { - $group_id = $this->create_group( $slug ); + + foreach ( $slugs as $slug ) { + $group_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT group_id FROM {$wpdb->actionscheduler_groups} WHERE slug=%s", $slug ) ); + + if ( empty( $group_id ) && $create_if_not_exists ) { + $group_id = $this->create_group( $slug ); + } + + if ( $group_id ) { + $group_ids[] = $group_id; + } } - return $group_id; + return $group_ids; } /** @@ -355,7 +376,7 @@ protected function make_action_from_db_record( $data ) { } $group = $data->group ? $data->group : ''; - return ActionScheduler::factory()->get_stored_action( $data->status, $data->hook, $args, $schedule, $group ); + return ActionScheduler::factory()->get_stored_action( $data->status, $data->hook, $args, $schedule, $group, $data->priority ); } /** @@ -684,7 +705,7 @@ protected function bulk_cancel_actions( $query_args ) { array( 'per_page' => 1000, 'status' => self::STATUS_PENDING, - 'orderby' => 'action_id', + 'orderby' => 'none', ) ); @@ -796,6 +817,33 @@ protected function generate_claim_id() { return $wpdb->insert_id; } + /** + * Set a claim filter. + * + * @param string $filter_name Claim filter name. + * @param mixed $filter_values Values to filter. + * @return void + */ + public function set_claim_filter( $filter_name, $filter_values ) { + if ( isset( $this->claim_filters[ $filter_name ] ) ) { + $this->claim_filters[ $filter_name ] = $filter_values; + } + } + + /** + * Get the claim filter value. + * + * @param string $filter_name Claim filter name. + * @return mixed + */ + public function get_claim_filter( $filter_name ) { + if ( isset( $this->claim_filters[ $filter_name ] ) ) { + return $this->claim_filters[ $filter_name ]; + } + + return ''; + } + /** * Mark actions claimed. * @@ -813,9 +861,8 @@ protected function claim_actions( $claim_id, $limit, \DateTime $before_date = nu /** @var \wpdb $wpdb */ global $wpdb; - $now = as_get_datetime_object(); - $date = is_null( $before_date ) ? $now : clone $before_date; - + $now = as_get_datetime_object(); + $date = is_null( $before_date ) ? $now : clone $before_date; // can't use $wpdb->update() because of the <= condition. $update = "UPDATE {$wpdb->actionscheduler_actions} SET claim_id=%d, last_attempt_gmt=%s, last_attempt_local=%s"; $params = array( @@ -824,6 +871,18 @@ protected function claim_actions( $claim_id, $limit, \DateTime $before_date = nu current_time( 'mysql' ), ); + // Set claim filters. + if ( ! empty( $hooks ) ) { + $this->set_claim_filter( 'hooks', $hooks ); + } else { + $hooks = $this->get_claim_filter( 'hooks' ); + } + if ( ! empty( $group ) ) { + $this->set_claim_filter( 'group', $group ); + } else { + $group = $this->get_claim_filter( 'group' ); + } + $where = 'WHERE claim_id = 0 AND scheduled_date_gmt <= %s AND status=%s'; $params[] = $date->format( 'Y-m-d H:i:s' ); $params[] = self::STATUS_PENDING; @@ -834,18 +893,33 @@ protected function claim_actions( $claim_id, $limit, \DateTime $before_date = nu $params = array_merge( $params, array_values( $hooks ) ); } - if ( ! empty( $group ) ) { - - $group_id = $this->get_group_id( $group, false ); + $group_operator = 'IN'; + if ( empty( $group ) ) { + $group = $this->get_claim_filter( 'exclude-groups' ); + $group_operator = 'NOT IN'; + } - // throw exception if no matching group found, this matches ActionScheduler_wpPostStore's behaviour. - if ( empty( $group_id ) ) { - /* translators: %s: group name */ - throw new InvalidArgumentException( sprintf( __( 'The group "%s" does not exist.', 'action-scheduler' ), $group ) ); + if ( ! empty( $group ) ) { + $group_ids = $this->get_group_ids( $group, false ); + + // throw exception if no matching group(s) found, this matches ActionScheduler_wpPostStore's behaviour. + if ( empty( $group_ids ) ) { + throw new InvalidArgumentException( + sprintf( + /* translators: %s: group name(s) */ + _n( + 'The group "%s" does not exist.', + 'The groups "%s" do not exist.', + is_array( $group ) ? count( $group ) : 1, + 'action-scheduler' + ), + $group + ) + ); } - $where .= ' AND group_id = %d'; - $params[] = $group_id; + $id_list = implode( ',', array_map( 'intval', $group_ids ) ); + $where .= " AND group_id {$group_operator} ( $id_list )"; } /** @@ -855,13 +929,23 @@ protected function claim_actions( $claim_id, $limit, \DateTime $before_date = nu * * @param string $order_by_sql */ - $order = apply_filters( 'action_scheduler_claim_actions_order_by', 'ORDER BY attempts ASC, scheduled_date_gmt ASC, action_id ASC' ); + $order = apply_filters( 'action_scheduler_claim_actions_order_by', 'ORDER BY priority ASC, attempts ASC, scheduled_date_gmt ASC, action_id ASC' ); $params[] = $limit; $sql = $wpdb->prepare( "{$update} {$where} {$order} LIMIT %d", $params ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders $rows_affected = $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( false === $rows_affected ) { - throw new \RuntimeException( __( 'Unable to claim actions. Database error.', 'action-scheduler' ) ); + $error = empty( $wpdb->last_error ) + ? _x( 'unknown', 'database error', 'action-scheduler' ) + : $wpdb->last_error; + + throw new \RuntimeException( + sprintf( + /* translators: %s database error. */ + __( 'Unable to claim actions. Database error: %s.', 'action-scheduler' ), + $error + ) + ); } return (int) $rows_affected; @@ -912,7 +996,7 @@ public function find_actions_by_claim_id( $claim_id ) { $cut_off = $before_date->format( 'Y-m-d H:i:s' ); $sql = $wpdb->prepare( - "SELECT action_id, scheduled_date_gmt FROM {$wpdb->actionscheduler_actions} WHERE claim_id = %d", + "SELECT action_id, scheduled_date_gmt FROM {$wpdb->actionscheduler_actions} WHERE claim_id = %d ORDER BY priority ASC, attempts ASC, scheduled_date_gmt ASC, action_id ASC", $claim_id ); @@ -955,7 +1039,7 @@ public function release_claim( ActionScheduler_ActionClaim $claim ) { if ( $row_updates < count( $action_ids ) ) { throw new RuntimeException( sprintf( - __( 'Unable to release actions from claim id %d.', 'woocommerce' ), + __( 'Unable to release actions from claim id %d.', 'action-scheduler' ), $claim->get_id() ) ); @@ -1005,6 +1089,8 @@ public function mark_failure( $action_id ) { /** * Add execution message to action log. * + * @throws Exception If the action status cannot be updated to self::STATUS_RUNNING ('in-progress'). + * * @param int $action_id Action ID. * * @return void @@ -1015,7 +1101,20 @@ public function log_execution( $action_id ) { $sql = "UPDATE {$wpdb->actionscheduler_actions} SET attempts = attempts+1, status=%s, last_attempt_gmt = %s, last_attempt_local = %s WHERE action_id = %d"; $sql = $wpdb->prepare( $sql, self::STATUS_RUNNING, current_time( 'mysql', true ), current_time( 'mysql' ), $action_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $status_updated = $wpdb->query( $sql ); + + if ( ! $status_updated ) { + throw new Exception( + sprintf( + /* translators: 1: action ID. 2: status slug. */ + __( 'Unable to update the status of action %1$d to %2$s.', 'action-scheduler' ), + $action_id, + self::STATUS_RUNNING + ) + ); + } } /** diff --git a/inc/Dependencies/ActionScheduler/classes/data-stores/ActionScheduler_wpPostStore.php b/inc/Dependencies/ActionScheduler/classes/data-stores/ActionScheduler_wpPostStore.php index 7883ca82b..7c6b06d1c 100644 --- a/inc/Dependencies/ActionScheduler/classes/data-stores/ActionScheduler_wpPostStore.php +++ b/inc/Dependencies/ActionScheduler/classes/data-stores/ActionScheduler_wpPostStore.php @@ -936,6 +936,8 @@ private function get_post_column( $action_id, $column_name ) { /** * Log Execution. * + * @throws Exception If the action status cannot be updated to self::STATUS_RUNNING ('in-progress'). + * * @param string $action_id Action ID. */ public function log_execution( $action_id ) { @@ -947,7 +949,7 @@ public function log_execution( $action_id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->query( + $status_updated = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->posts} SET menu_order = menu_order+1, post_status=%s, post_modified_gmt = %s, post_modified = %s WHERE ID = %d AND post_type = %s", self::STATUS_RUNNING, @@ -957,6 +959,17 @@ public function log_execution( $action_id ) { self::POST_TYPE ) ); + + if ( ! $status_updated ) { + throw new Exception( + sprintf( + /* translators: 1: action ID. 2: status slug. */ + __( 'Unable to update the status of action %1$d to %2$s.', 'action-scheduler' ), + $action_id, + self::STATUS_RUNNING + ) + ); + } } /** diff --git a/inc/Dependencies/ActionScheduler/classes/migration/Runner.php b/inc/Dependencies/ActionScheduler/classes/migration/Runner.php index 867c5de68..2304a79ad 100644 --- a/inc/Dependencies/ActionScheduler/classes/migration/Runner.php +++ b/inc/Dependencies/ActionScheduler/classes/migration/Runner.php @@ -79,7 +79,7 @@ public function run( $batch_size = 10 ) { if ( $this->progress_bar ) { /* translators: %d: amount of actions */ - $this->progress_bar->set_message( sprintf( _n( 'Migrating %d action', 'Migrating %d actions', $batch_size, 'action-scheduler' ), number_format_i18n( $batch_size ) ) ); + $this->progress_bar->set_message( sprintf( _n( 'Migrating %d action', 'Migrating %d actions', $batch_size, 'action-scheduler' ), $batch_size ) ); $this->progress_bar->set_count( $batch_size ); } diff --git a/inc/Dependencies/ActionScheduler/classes/schema/ActionScheduler_StoreSchema.php b/inc/Dependencies/ActionScheduler/classes/schema/ActionScheduler_StoreSchema.php index d52f27f6f..a0bd8cb20 100644 --- a/inc/Dependencies/ActionScheduler/classes/schema/ActionScheduler_StoreSchema.php +++ b/inc/Dependencies/ActionScheduler/classes/schema/ActionScheduler_StoreSchema.php @@ -16,7 +16,7 @@ class ActionScheduler_StoreSchema extends ActionScheduler_Abstract_Schema { /** * @var int Increment this value to trigger a schema update. */ - protected $schema_version = 6; + protected $schema_version = 7; public function __construct() { $this->tables = [ @@ -38,6 +38,7 @@ protected function get_table_definition( $table ) { $table_name = $wpdb->$table; $charset_collate = $wpdb->get_charset_collate(); $max_index_length = 191; // @see wp_get_db_schema() + $hook_status_scheduled_date_gmt_max_index_length = $max_index_length - 20 - 8; // - status, - scheduled_date_gmt $default_date = self::DEFAULT_DATE; switch ( $table ) { @@ -49,6 +50,7 @@ protected function get_table_definition( $table ) { status varchar(20) NOT NULL, scheduled_date_gmt datetime NULL default '{$default_date}', scheduled_date_local datetime NULL default '{$default_date}', + priority tinyint unsigned NOT NULL default '10', args varchar($max_index_length), schedule longtext, group_id bigint(20) unsigned NOT NULL default '0', @@ -58,8 +60,8 @@ protected function get_table_definition( $table ) { claim_id bigint(20) unsigned NOT NULL default '0', extended_args varchar(8000) DEFAULT NULL, PRIMARY KEY (action_id), - KEY hook (hook($max_index_length)), - KEY status (status), + KEY hook_status_scheduled_date_gmt (hook($hook_status_scheduled_date_gmt_max_index_length), status, scheduled_date_gmt), + KEY status_scheduled_date_gmt (status, scheduled_date_gmt), KEY scheduled_date_gmt (scheduled_date_gmt), KEY args (args($max_index_length)), KEY group_id (group_id), diff --git a/inc/Dependencies/ActionScheduler/functions.php b/inc/Dependencies/ActionScheduler/functions.php index 09ef353d9..cf803da3f 100644 --- a/inc/Dependencies/ActionScheduler/functions.php +++ b/inc/Dependencies/ActionScheduler/functions.php @@ -12,10 +12,11 @@ * @param array $args Arguments to pass when the hook triggers. * @param string $group The group to assign this job to. * @param bool $unique Whether the action should be unique. + * @param int $priority Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255. * - * @return int The action ID. + * @return int The action ID. Zero if there was an error scheduling the action. */ -function as_enqueue_async_action( $hook, $args = array(), $group = '', $unique = false ) { +function as_enqueue_async_action( $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) { if ( ! ActionScheduler::is_initialized( __FUNCTION__ ) ) { return 0; } @@ -33,13 +34,23 @@ function as_enqueue_async_action( $hook, $args = array(), $group = '', $unique = * @param string $hook Action hook. * @param array $args Action arguments. * @param string $group Action group. + * @param int $priority Action priority. */ - $pre = apply_filters( 'pre_as_enqueue_async_action', null, $hook, $args, $group ); + $pre = apply_filters( 'pre_as_enqueue_async_action', null, $hook, $args, $group, $priority ); if ( null !== $pre ) { return is_int( $pre ) ? $pre : 0; } - return ActionScheduler::factory()->async_unique( $hook, $args, $group, $unique ); + return ActionScheduler::factory()->create( + array( + 'type' => 'async', + 'hook' => $hook, + 'arguments' => $args, + 'group' => $group, + 'unique' => $unique, + 'priority' => $priority, + ) + ); } /** @@ -50,10 +61,11 @@ function as_enqueue_async_action( $hook, $args = array(), $group = '', $unique = * @param array $args Arguments to pass when the hook triggers. * @param string $group The group to assign this job to. * @param bool $unique Whether the action should be unique. + * @param int $priority Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255. * - * @return int The action ID. + * @return int The action ID. Zero if there was an error scheduling the action. */ -function as_schedule_single_action( $timestamp, $hook, $args = array(), $group = '', $unique = false ) { +function as_schedule_single_action( $timestamp, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) { if ( ! ActionScheduler::is_initialized( __FUNCTION__ ) ) { return 0; } @@ -72,13 +84,24 @@ function as_schedule_single_action( $timestamp, $hook, $args = array(), $group = * @param string $hook Action hook. * @param array $args Action arguments. * @param string $group Action group. + * @param int $priorities Action priority. */ - $pre = apply_filters( 'pre_as_schedule_single_action', null, $timestamp, $hook, $args, $group ); + $pre = apply_filters( 'pre_as_schedule_single_action', null, $timestamp, $hook, $args, $group, $priority ); if ( null !== $pre ) { return is_int( $pre ) ? $pre : 0; } - return ActionScheduler::factory()->single_unique( $hook, $args, $timestamp, $group, $unique ); + return ActionScheduler::factory()->create( + array( + 'type' => 'single', + 'hook' => $hook, + 'arguments' => $args, + 'when' => $timestamp, + 'group' => $group, + 'unique' => $unique, + 'priority' => $priority, + ) + ); } /** @@ -90,14 +113,34 @@ function as_schedule_single_action( $timestamp, $hook, $args = array(), $group = * @param array $args Arguments to pass when the hook triggers. * @param string $group The group to assign this job to. * @param bool $unique Whether the action should be unique. + * @param int $priority Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255. * - * @return int The action ID. + * @return int The action ID. Zero if there was an error scheduling the action. */ -function as_schedule_recurring_action( $timestamp, $interval_in_seconds, $hook, $args = array(), $group = '', $unique = false ) { +function as_schedule_recurring_action( $timestamp, $interval_in_seconds, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) { if ( ! ActionScheduler::is_initialized( __FUNCTION__ ) ) { return 0; } + $interval = (int) $interval_in_seconds; + + // We expect an integer and allow it to be passed using float and string types, but otherwise + // should reject unexpected values. + if ( ! is_numeric( $interval_in_seconds ) || $interval_in_seconds != $interval ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: provided value 2: provided type. */ + esc_html__( 'An integer was expected but "%1$s" (%2$s) was received.', 'action-scheduler' ), + esc_html( $interval_in_seconds ), + esc_html( gettype( $interval_in_seconds ) ) + ), + '3.6.0' + ); + + return 0; + } + /** * Provides an opportunity to short-circuit the default process for enqueuing recurring * actions. @@ -113,13 +156,25 @@ function as_schedule_recurring_action( $timestamp, $interval_in_seconds, $hook, * @param string $hook Action hook. * @param array $args Action arguments. * @param string $group Action group. + * @param int $priority Action priority. */ - $pre = apply_filters( 'pre_as_schedule_recurring_action', null, $timestamp, $interval_in_seconds, $hook, $args, $group ); + $pre = apply_filters( 'pre_as_schedule_recurring_action', null, $timestamp, $interval_in_seconds, $hook, $args, $group, $priority ); if ( null !== $pre ) { return is_int( $pre ) ? $pre : 0; } - return ActionScheduler::factory()->recurring_unique( $hook, $args, $timestamp, $interval_in_seconds, $group, $unique ); + return ActionScheduler::factory()->create( + array( + 'type' => 'recurring', + 'hook' => $hook, + 'arguments' => $args, + 'when' => $timestamp, + 'pattern' => $interval_in_seconds, + 'group' => $group, + 'unique' => $unique, + 'priority' => $priority, + ) + ); } /** @@ -143,10 +198,11 @@ function as_schedule_recurring_action( $timestamp, $interval_in_seconds, $hook, * @param array $args Arguments to pass when the hook triggers. * @param string $group The group to assign this job to. * @param bool $unique Whether the action should be unique. + * @param int $priority Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255. * - * @return int The action ID. + * @return int The action ID. Zero if there was an error scheduling the action. */ -function as_schedule_cron_action( $timestamp, $schedule, $hook, $args = array(), $group = '', $unique = false ) { +function as_schedule_cron_action( $timestamp, $schedule, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) { if ( ! ActionScheduler::is_initialized( __FUNCTION__ ) ) { return 0; } @@ -166,13 +222,25 @@ function as_schedule_cron_action( $timestamp, $schedule, $hook, $args = array(), * @param string $hook Action hook. * @param array $args Action arguments. * @param string $group Action group. + * @param int $priority Action priority. */ - $pre = apply_filters( 'pre_as_schedule_cron_action', null, $timestamp, $schedule, $hook, $args, $group ); + $pre = apply_filters( 'pre_as_schedule_cron_action', null, $timestamp, $schedule, $hook, $args, $group, $priority ); if ( null !== $pre ) { return is_int( $pre ) ? $pre : 0; } - return ActionScheduler::factory()->cron_unique( $hook, $args, $timestamp, $schedule, $group, $unique ); + return ActionScheduler::factory()->create( + array( + 'type' => 'cron', + 'hook' => $hook, + 'arguments' => $args, + 'when' => $timestamp, + 'pattern' => $schedule, + 'group' => $group, + 'unique' => $unique, + 'priority' => $priority, + ) + ); } /** @@ -215,9 +283,10 @@ function as_unschedule_action( $hook, $args = array(), $group = '' ) { ActionScheduler::logger()->log( $action_id, sprintf( - /* translators: %s is the name of the hook to be cancelled. */ - __( 'Caught exception while cancelling action: %s', 'action-scheduler' ), - esc_attr( $hook ) + /* translators: %1$s is the name of the hook to be cancelled, %2$s is the exception message. */ + __( 'Caught exception while cancelling action "%1$s": %2$s', 'action-scheduler' ), + $hook, + $exception->getMessage() ) ); diff --git a/inc/Dependencies/ActionScheduler/readme.txt b/inc/Dependencies/ActionScheduler/readme.txt index 3518b1544..7e0cc7e77 100644 --- a/inc/Dependencies/ActionScheduler/readme.txt +++ b/inc/Dependencies/ActionScheduler/readme.txt @@ -1,10 +1,10 @@ === Action Scheduler === Contributors: Automattic, wpmuguru, claudiosanches, peterfabian1000, vedjain, jamosova, obliviousharmony, konamiman, sadowski, royho, barryhughes-1 Tags: scheduler, cron -Requires at least: 5.2 -Tested up to: 6.0 -Stable tag: 3.5.4 +Stable tag: 3.7.1 License: GPLv3 +Requires at least: 6.2 +Tested up to: 6.4 Requires PHP: 5.6 Action Scheduler - Job Queue for WordPress @@ -47,6 +47,66 @@ Collaboration is cool. We'd love to work with you to improve Action Scheduler. [ == Changelog == += 3.7.1 - 2023-12-13 = +* Release/3.7.0. +* Tweak - WP 6.4 compatibility. +* update semver to 5.7.2 because of a security vulnerability in 5.7.1. + += 3.7.0 - 2023-11-20 = +* Important: starting with this release, Action Scheduler follows an L-2 version policy (WordPress, and consequently PHP). +* Add extended indexes for hook_status_scheduled_date_gmt and status_sheduled_date_gmt. +* Catch and log exceptions thrown when actions can't be created, e.g. under a corrupt database schema. +* Release/3.6.4. +* Tweak - WP 6.4 compatibility. +* Update unit tests for upcoming dependency version policy. +* make sure hook action_scheduler_failed_execution can access original exception object. +* mention dependency version policy in usage.md. + += 3.6.4 - 2023-10-11 = +* Performance improvements when bulk cancelling actions. +* Dev-related fixes. + += 3.6.3 - 2023-09-13 = +* Use `_doing_it_wrong` in initialization check. + += 3.6.2 - 2023-08-09 = +* Add guidance about passing arguments. +* Atomic option locking. +* Improve bulk delete handling. +* Include database error in the exception message. +* Tweak - WP 6.3 compatibility. + += 3.6.1 - 2023-06-14 = +* Document new optional `$priority` arg for various API functions. +* Document the new `--exclude-groups` WP CLI option. +* Document the new `action_scheduler_init` hook. +* Ensure actions within each claim are executed in the expected order. +* Fix incorrect text domain. +* Remove SHOW TABLES usage when checking if tables exist. + += 3.6.0 - 2023-05-10 = +* Add $unique parameter to function signatures. +* Add a cast-to-int for extra safety before forming new DateTime object. +* Add a hook allowing exceptions for consistently failing recurring actions. +* Add action priorities. +* Add init hook. +* Always raise the time limit. +* Bump minimatch from 3.0.4 to 3.0.8. +* Bump yaml from 2.2.1 to 2.2.2. +* Defensive coding relating to gaps in declared schedule types. +* Do not process an action if it cannot be set to `in-progress`. +* Filter view labels (status names) should be translatable | #919. +* Fix WPCLI progress messages. +* Improve data-store initialization flow. +* Improve error handling across all supported PHP versions. +* Improve logic for flushing the runtime cache. +* Support exclusion of multiple groups. +* Update lint-staged and Node/NPM requirements. +* add CLI clean command. +* add CLI exclude-group filter. +* exclude past-due from list table all filter count. +* throwing an exception if as_schedule_recurring_action interval param is not of type integer. + = 3.5.4 - 2023-01-17 = * Add pre filters during action registration. * Async scheduling. diff --git a/inc/classes/Dependencies/deliciousbrains/wp-background-processing/classes/wp-async-request.php b/inc/classes/Dependencies/deliciousbrains/wp-background-processing/classes/wp-async-request.php index 3728718af..083c42093 100644 --- a/inc/classes/Dependencies/deliciousbrains/wp-background-processing/classes/wp-async-request.php +++ b/inc/classes/Dependencies/deliciousbrains/wp-background-processing/classes/wp-async-request.php @@ -5,6 +5,11 @@ * @package WP-Background-Processing */ +// phpcs:disable Generic.Commenting.DocComment.MissingShort +/** @noinspection PhpIllegalPsrClassPathInspection */ +/** @noinspection AutoloadingIssuesInspection */ +// phpcs:disable Generic.Commenting.DocComment.MissingShort + /** * Abstract Imagify_WP_Async_Request class. * @@ -51,7 +56,7 @@ abstract class Imagify_WP_Async_Request { protected $data = array(); /** - * Initiate new async request + * Initiate new async request. */ public function __construct() { $this->identifier = $this->prefix . '_' . $this->action; @@ -61,7 +66,7 @@ public function __construct() { } /** - * Set data used during the request + * Set data used during the request. * * @param array $data Data. * @@ -74,9 +79,9 @@ public function data( $data ) { } /** - * Dispatch the async request + * Dispatch the async request. * - * @return array|WP_Error + * @return array|WP_Error|false HTTP Response array, WP_Error on failure, or false if not attempted. */ public function dispatch() { $url = add_query_arg( $this->get_query_args(), $this->get_query_url() ); @@ -86,7 +91,7 @@ public function dispatch() { } /** - * Get query args + * Get query args. * * @return array */ @@ -109,7 +114,7 @@ protected function get_query_args() { } /** - * Get query URL + * Get query URL. * * @return string */ @@ -129,7 +134,7 @@ protected function get_query_url() { } /** - * Get post args + * Get post args. * * @return array */ @@ -139,11 +144,11 @@ protected function get_post_args() { } $args = array( - 'timeout' => 0.01, + 'timeout' => 5, 'blocking' => false, 'body' => $this->data, - 'cookies' => $_COOKIE, - 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), + 'cookies' => $_COOKIE, // Passing cookies ensures request is performed as initiating user. + 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), // Local requests, fine to pass false. ); /** @@ -155,27 +160,49 @@ protected function get_post_args() { } /** - * Maybe handle + * Maybe handle a dispatched request. * * Check for correct nonce and pass to handler. + * + * @return void|mixed */ public function maybe_handle() { - // Don't lock up other requests while processing + // Don't lock up other requests while processing. session_write_close(); check_ajax_referer( $this->identifier, 'nonce' ); $this->handle(); - wp_die(); + return $this->maybe_wp_die(); + } + + /** + * Should the process exit with wp_die? + * + * @param mixed $return What to return if filter says don't die, default is null. + * + * @return void|mixed + * @noinspection ForgottenDebugOutputInspection + */ + protected function maybe_wp_die( $return = null ) { + /** + * Should wp_die be used? + * + * @return bool + */ + if ( apply_filters( $this->identifier . '_wp_die', true ) ) { + wp_die(); + } + + return $return; } /** - * Handle + * Handle a dispatched request. * * Override this method to perform any actions required * during the async request. */ abstract protected function handle(); - } diff --git a/inc/classes/Dependencies/deliciousbrains/wp-background-processing/classes/wp-background-process.php b/inc/classes/Dependencies/deliciousbrains/wp-background-processing/classes/wp-background-process.php index 3928be667..56bbe8470 100644 --- a/inc/classes/Dependencies/deliciousbrains/wp-background-processing/classes/wp-background-process.php +++ b/inc/classes/Dependencies/deliciousbrains/wp-background-processing/classes/wp-background-process.php @@ -5,6 +5,11 @@ * @package WP-Background-Processing */ +// phpcs:disable Generic.Commenting.DocComment.MissingShort +/** @noinspection PhpIllegalPsrClassPathInspection */ +/** @noinspection AutoloadingIssuesInspection */ +// phpcs:disable Generic.Commenting.DocComment.MissingShort + /** * Abstract Imagify_WP_Background_Process class. * @@ -36,7 +41,7 @@ abstract class Imagify_WP_Background_Process extends Imagify_WP_Async_Request { /** * Cron_hook_identifier * - * @var mixed + * @var string * @access protected */ protected $cron_hook_identifier; @@ -44,13 +49,27 @@ abstract class Imagify_WP_Background_Process extends Imagify_WP_Async_Request { /** * Cron_interval_identifier * - * @var mixed + * @var string * @access protected */ protected $cron_interval_identifier; /** - * Initiate new background process + * The status set when process is cancelling. + * + * @var int + */ + const STATUS_CANCELLED = 1; + + /** + * The status set when process is paused or pausing. + * + * @var int; + */ + const STATUS_PAUSED = 2; + + /** + * Initiate new background process. */ public function __construct() { parent::__construct(); @@ -59,16 +78,22 @@ public function __construct() { $this->cron_interval_identifier = $this->identifier . '_cron_interval'; add_action( $this->cron_hook_identifier, array( $this, 'handle_cron_healthcheck' ) ); + // phpcs:ignore WordPress.WP.CronInterval.ChangeDetected add_filter( 'cron_schedules', array( $this, 'schedule_cron_healthcheck' ) ); } /** - * Dispatch + * Schedule the cron healthcheck and dispatch an async request to start processing the queue. * * @access public - * @return void + * @return array|WP_Error|false HTTP Response array, WP_Error on failure, or false if not attempted. */ public function dispatch() { + if ( $this->is_processing() ) { + // Process already running. + return false; + } + // Schedule the cron healthcheck. $this->schedule_event(); @@ -77,7 +102,9 @@ public function dispatch() { } /** - * Push to queue + * Push to the queue. + * + * Note, save must be called in order to persist queued items to a batch for processing. * * @param mixed $data Data. * @@ -90,7 +117,7 @@ public function push_to_queue( $data ) { } /** - * Save queue + * Save the queued items for future processing. * * @return $this */ @@ -101,11 +128,14 @@ public function save() { update_site_option( $key, $this->data ); } + // Clean out data so that new data isn't prepended with closed session's data. + $this->data = array(); + return $this; } /** - * Update queue + * Update a batch's queued items. * * @param string $key Key. * @param array $data Data. @@ -121,7 +151,7 @@ public function update( $key, $data ) { } /** - * Delete queue + * Delete a batch of queued items. * * @param string $key Key. * @@ -134,83 +164,209 @@ public function delete( $key ) { } /** - * Generate key + * Delete entire job queue. + */ + public function delete_all() { + $batches = $this->get_batches(); + + foreach ( $batches as $batch ) { + $this->delete( $batch->key ); + } + + delete_site_option( $this->get_status_key() ); + + $this->cancelled(); + } + + /** + * Cancel job on next batch. + */ + public function cancel() { + update_site_option( $this->get_status_key(), self::STATUS_CANCELLED ); + + // Just in case the job was paused at the time. + $this->dispatch(); + } + + /** + * Has the process been cancelled? + * + * @return bool + */ + public function is_cancelled() { + $status = get_site_option( $this->get_status_key(), 0 ); + + return absint( $status ) === self::STATUS_CANCELLED; + } + + /** + * Called when background process has been cancelled. + */ + protected function cancelled() { + do_action( $this->identifier . '_cancelled' ); + } + + /** + * Pause job on next batch. + */ + public function pause() { + update_site_option( $this->get_status_key(), self::STATUS_PAUSED ); + } + + /** + * Is the job paused? + * + * @return bool + */ + public function is_paused() { + $status = get_site_option( $this->get_status_key(), 0 ); + + return absint( $status ) === self::STATUS_PAUSED; + } + + /** + * Called when background process has been paused. + */ + protected function paused() { + do_action( $this->identifier . '_paused' ); + } + + /** + * Resume job. + */ + public function resume() { + delete_site_option( $this->get_status_key() ); + + $this->schedule_event(); + $this->dispatch(); + $this->resumed(); + } + + /** + * Called when background process has been resumed. + */ + protected function resumed() { + do_action( $this->identifier . '_resumed' ); + } + + /** + * Is queued? + * + * @return bool + */ + public function is_queued() { + return ! $this->is_queue_empty(); + } + + /** + * Is the tool currently active, e.g. starting, working, paused or cleaning up? + * + * @return bool + */ + public function is_active() { + return $this->is_queued() || $this->is_processing() || $this->is_paused() || $this->is_cancelled(); + } + + /** + * Generate key for a batch. * * Generates a unique key based on microtime. Queue items are * given a unique key so that they can be merged upon save. * - * @param int $length Length. + * @param int $length Optional max length to trim key to, defaults to 64 characters. + * @param string $key Optional string to append to identifier before hash, defaults to "batch". * * @return string */ - protected function generate_key( $length = 64 ) { - $unique = md5( microtime() . rand() ); - $prepend = $this->identifier . '_batch_'; + protected function generate_key( $length = 64, $key = 'batch' ) { + $unique = md5( microtime() . wp_rand() ); + $prepend = $this->identifier . '_' . $key . '_'; return substr( $prepend . $unique, 0, $length ); } /** - * Maybe process queue + * Get the status key. + * + * @return string + */ + protected function get_status_key() { + return $this->identifier . '_status'; + } + + /** + * Maybe process a batch of queued items. * * Checks whether data exists within the queue and that * the process is not already running. */ public function maybe_handle() { - // Don't lock up other requests while processing + // Don't lock up other requests while processing. session_write_close(); - if ( $this->is_process_running() ) { + if ( $this->is_processing() ) { // Background process already running. - wp_die(); + return $this->maybe_wp_die(); + } + + if ( $this->is_cancelled() ) { + $this->clear_scheduled_event(); + $this->delete_all(); + + return $this->maybe_wp_die(); + } + + if ( $this->is_paused() ) { + $this->clear_scheduled_event(); + $this->paused(); + + return $this->maybe_wp_die(); } if ( $this->is_queue_empty() ) { // No data to process. - wp_die(); + return $this->maybe_wp_die(); } check_ajax_referer( $this->identifier, 'nonce' ); $this->handle(); - wp_die(); + return $this->maybe_wp_die(); } /** - * Is queue empty + * Is queue empty? * * @return bool + * @noinspection IsEmptyFunctionUsageInspection */ protected function is_queue_empty() { - global $wpdb; - - $table = $wpdb->options; - $column = 'option_name'; - - if ( is_multisite() ) { - $table = $wpdb->sitemeta; - $column = 'meta_key'; - } - - $key = $wpdb->esc_like( $this->identifier . '_batch_' ) . '%'; - - $count = $wpdb->get_var( $wpdb->prepare( " - SELECT COUNT(*) - FROM {$table} - WHERE {$column} LIKE %s - ", $key ) ); - - return ( $count > 0 ) ? false : true; + return empty( $this->get_batch() ); } /** - * Is process running + * Is process running? * * Check whether the current process is already running * in a background process. + * + * @return bool + * + * @deprecated 1.1.0 Superseded. + * @see is_processing() + * @noinspection PhpUnused */ protected function is_process_running() { + return $this->is_processing(); + } + + /** + * Is the background process currently running? + * + * @return bool + */ + public function is_processing() { if ( get_site_transient( $this->identifier . '_process_lock' ) ) { // Process already running. return true; @@ -220,7 +376,7 @@ protected function is_process_running() { } /** - * Lock process + * Lock process. * * Lock the process so that multiple instances can't run simultaneously. * Override if applicable, but the duration should be greater than that @@ -236,7 +392,7 @@ protected function lock_process() { } /** - * Unlock process + * Unlock process. * * Unlock the process so that other instances can spawn. * @@ -249,13 +405,34 @@ protected function unlock_process() { } /** - * Get batch + * Get batch. * - * @return stdClass Return the first batch from the queue + * @return stdClass Return the first batch of queued items. */ protected function get_batch() { + return array_reduce( + $this->get_batches( 1 ), + static function ( $carry, $batch ) { + return $batch; + }, + array() + ); + } + + /** + * Get batches. + * + * @param int $limit Number of batches to return, defaults to all. + * + * @return array of stdClass + */ + public function get_batches( $limit = 0 ) { global $wpdb; + if ( empty( $limit ) || ! is_int( $limit ) ) { + $limit = 0; + } + $table = $wpdb->options; $column = 'option_name'; $key_column = 'option_id'; @@ -270,30 +447,68 @@ protected function get_batch() { $key = $wpdb->esc_like( $this->identifier . '_batch_' ) . '%'; - $query = $wpdb->get_row( $wpdb->prepare( " + $sql = ' SELECT * - FROM {$table} - WHERE {$column} LIKE %s - ORDER BY {$key_column} ASC - LIMIT 1 - ", $key ) ); + FROM ' . $table . ' + WHERE ' . $column . ' LIKE %s + ORDER BY ' . $key_column . ' + '; + + $args = array( $key ); + + if ( ! empty( $limit ) ) { + $sql .= ' LIMIT %d'; + + $args[] = $limit; + } + + $items = $wpdb->get_results( $wpdb->prepare( $sql, $args ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - $batch = new stdClass(); - $batch->key = $query->$column; - $batch->data = maybe_unserialize( $query->$value_column ); + $batches = array(); - return $batch; + if ( ! empty( $items ) ) { + $batches = array_map( + static function ( $item ) use ( $column, $value_column ) { + $batch = new stdClass(); + $batch->key = $item->{$column}; + $batch->data = maybe_unserialize( $item->{$value_column} ); + + return $batch; + }, + $items + ); + } + + return $batches; } /** - * Handle + * Handle a dispatched request. * * Pass each queue item to the task handler, while remaining * within server memory and time limit constraints. + * + * @noinspection DisconnectedForeachInstructionInspection */ protected function handle() { $this->lock_process(); + /** + * Number of seconds to sleep between batches. Defaults to 0 seconds, minimum 0. + * + * @param int $seconds + */ + $throttle_seconds = max( + 0, + apply_filters( + $this->identifier . '_seconds_between_batches', + apply_filters( + $this->prefix . '_seconds_between_batches', + 0 + ) + ) + ); + do { $batch = $this->get_batch(); @@ -306,19 +521,25 @@ protected function handle() { unset( $batch->data[ $key ] ); } - if ( $this->time_exceeded() || $this->memory_exceeded() ) { - // Batch limits reached. + // Keep the batch up to date while processing it. + if ( ! empty( $batch->data ) ) { + $this->update( $batch->key, $batch->data ); + } + + // Let the server breathe a little. + sleep( $throttle_seconds ); + + // Batch limits reached, or pause or cancel request. + if ( $this->time_exceeded() || $this->memory_exceeded() || $this->is_paused() || $this->is_cancelled() ) { break; } } - // Update or delete current batch. - if ( ! empty( $batch->data ) ) { - $this->update( $batch->key, $batch->data ); - } else { + // Delete current batch if fully processed. + if ( empty( $batch->data ) ) { $this->delete( $batch->key ); } - } while ( ! $this->time_exceeded() && ! $this->memory_exceeded() && ! $this->is_queue_empty() ); + } while ( ! $this->time_exceeded() && ! $this->memory_exceeded() && ! $this->is_queue_empty() && ! $this->is_paused() && ! $this->is_cancelled() ); $this->unlock_process(); @@ -329,11 +550,11 @@ protected function handle() { $this->complete(); } - wp_die(); + return $this->maybe_wp_die(); } /** - * Memory exceeded + * Memory exceeded? * * Ensures the batch process never exceeds 90% * of the maximum WordPress memory. @@ -353,7 +574,7 @@ protected function memory_exceeded() { } /** - * Get memory limit + * Get memory limit in bytes. * * @return int */ @@ -365,7 +586,7 @@ protected function get_memory_limit() { $memory_limit = '128M'; } - if ( ! $memory_limit || - 1 === intval( $memory_limit ) ) { + if ( ! $memory_limit || -1 === (int) $memory_limit ) { // Unlimited, set to 32GB. $memory_limit = '32000M'; } @@ -374,7 +595,7 @@ protected function get_memory_limit() { } /** - * Time exceeded. + * Time limit exceeded? * * Ensures the batch never exceeds a sensible time limit. * A timeout limit of 30s is common on shared hosting. @@ -385,7 +606,10 @@ protected function time_exceeded() { $finish = $this->start_time + apply_filters( $this->identifier . '_default_time_limit', 20 ); // 20 seconds $return = false; - if ( time() >= $finish ) { + if ( + ! ( defined( 'WP_CLI' ) && WP_CLI ) && + time() >= $finish + ) { $return = true; } @@ -393,18 +617,29 @@ protected function time_exceeded() { } /** - * Complete. + * Complete processing. * * Override if applicable, but ensure that the below actions are * performed, or, call parent::complete(). */ protected function complete() { - // Unschedule the cron healthcheck. + delete_site_option( $this->get_status_key() ); + + // Remove the cron healthcheck job from the cron schedule. $this->clear_scheduled_event(); + + $this->completed(); + } + + /** + * Called when background process has completed. + */ + protected function completed() { + do_action( $this->identifier . '_completed' ); } /** - * Schedule cron healthcheck + * Schedule the cron healthcheck job. * * @access public * @@ -413,29 +648,35 @@ protected function complete() { * @return mixed */ public function schedule_cron_healthcheck( $schedules ) { - $interval = apply_filters( $this->identifier . '_cron_interval', 5 ); + $interval = apply_filters( $this->cron_interval_identifier, 5 ); if ( property_exists( $this, 'cron_interval' ) ) { - $interval = apply_filters( $this->identifier . '_cron_interval', $this->cron_interval ); + $interval = apply_filters( $this->cron_interval_identifier, $this->cron_interval ); } - // Adds every 5 minutes to the existing schedules. - $schedules[ $this->identifier . '_cron_interval' ] = array( + if ( 1 === $interval ) { + $display = __( 'Every Minute' ); + } else { + $display = sprintf( __( 'Every %d Minutes' ), $interval ); + } + + // Adds an "Every NNN Minute(s)" schedule to the existing cron schedules. + $schedules[ $this->cron_interval_identifier ] = array( 'interval' => MINUTE_IN_SECONDS * $interval, - 'display' => sprintf( __( 'Every %d Minutes' ), $interval ), + 'display' => $display, ); return $schedules; } /** - * Handle cron healthcheck + * Handle cron healthcheck event. * * Restart the background process if not already running * and data exists in the queue. */ public function handle_cron_healthcheck() { - if ( $this->is_process_running() ) { + if ( $this->is_processing() ) { // Background process already running. exit; } @@ -446,13 +687,11 @@ public function handle_cron_healthcheck() { exit; } - $this->handle(); - - exit; + $this->dispatch(); } /** - * Schedule event + * Schedule the cron healthcheck event. */ protected function schedule_event() { if ( ! wp_next_scheduled( $this->cron_hook_identifier ) ) { @@ -461,7 +700,7 @@ protected function schedule_event() { } /** - * Clear scheduled event + * Clear scheduled cron healthcheck event. */ protected function clear_scheduled_event() { $timestamp = wp_next_scheduled( $this->cron_hook_identifier ); @@ -472,24 +711,20 @@ protected function clear_scheduled_event() { } /** - * Cancel Process + * Cancel the background process. * - * Stop processing queue items, clear cronjob and delete batch. + * Stop processing queue items, clear cron job and delete batch. * + * @deprecated 1.1.0 Superseded. + * @see cancel() + * @noinspection PhpUnused */ public function cancel_process() { - if ( ! $this->is_queue_empty() ) { - $batch = $this->get_batch(); - - $this->delete( $batch->key ); - - wp_clear_scheduled_hook( $this->cron_hook_identifier ); - } - + $this->cancel(); } /** - * Task + * Perform task with queued item. * * Override this method to perform any actions required on each * queue item. Return the modified item for further processing @@ -501,5 +736,4 @@ public function cancel_process() { * @return mixed */ abstract protected function task( $item ); - -} \ No newline at end of file +}