diff --git a/.travis.yml b/.travis.yml index 8f3440a..6827872 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,6 +18,7 @@ install: - composer install - bash tests/install-tests.sh wordpress_test root '' 127.0.0.1 $WP_VERSION script: - - vendor/bin/phpunit --exclude-group cron + # 32656 tests are related to the pre_*_event filters which we're using already + - vendor/bin/phpunit --exclude-group 32656 notifications: email: false diff --git a/inc/class-job.php b/inc/class-job.php index 011add4..360e02d 100644 --- a/inc/class-job.php +++ b/inc/class-job.php @@ -58,8 +58,6 @@ public function save() { } } - wp_cache_delete( 'jobs', 'cavalcade-jobs' ); - if ( $this->is_created() ) { $where = [ 'id' => $this->id, @@ -69,6 +67,9 @@ public function save() { $result = $wpdb->insert( $this->get_table(), $data, $this->row_format( $data ) ); $this->id = $wpdb->insert_id; } + + self::flush_query_cache(); + wp_cache_set( "job::{$this->id}", $this, 'cavalcade-jobs' ); } public function delete( $options = [] ) { @@ -89,13 +90,13 @@ public function delete( $options = [] ) { ]; $result = $wpdb->delete( $this->get_table(), $where, $this->row_format( $where ) ); - wp_cache_delete( 'jobs', 'cavalcade-jobs' ); + self::flush_query_cache(); + wp_cache_delete( "job::{$this->id}", 'cavalcade-jobs' ); return (bool) $result; - } - protected static function get_table() { + public static function get_table() { global $wpdb; return $wpdb->base_prefix . 'cavalcade_jobs'; } @@ -127,6 +128,7 @@ protected static function to_instance( $row ) { $job->schedule = get_schedule_by_interval( $row->interval ); } + wp_cache_set( "job::{$job->id}", $job, 'cavalcade-jobs' ); return $job; } @@ -155,6 +157,11 @@ public static function get( $job ) { $job = absint( $job ); + $cached_job = wp_cache_get( "job::{$job}", 'cavalcade-jobs' ); + if ( $cached_job ) { + return $cached_job; + } + $suppress = $wpdb->suppress_errors(); $job = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . static::get_table() . ' WHERE id = %d', $job ) ); $wpdb->suppress_errors( $suppress ); @@ -172,10 +179,10 @@ public static function get( $job ) { * @param int|stdClass $site Site ID, or site object from {@see get_blog_details} * @param bool $include_completed Should we include completed jobs? * @param bool $include_failed Should we include failed jobs? + * @param bool $exclude_future Should we exclude future (not ready) jobs? * @return Job[]|WP_Error Jobs on success, error otherwise. */ - public static function get_by_site( $site, $include_completed = false, $include_failed = false ) { - global $wpdb; + public static function get_by_site( $site, $include_completed = false, $include_failed = false, $exclude_future = false ) { // Allow passing a site object in if ( is_object( $site ) && isset( $site->blog_id ) ) { @@ -186,39 +193,198 @@ public static function get_by_site( $site, $include_completed = false, $include_ return new WP_Error( 'cavalcade.job.invalid_site_id' ); } - if ( ! $include_completed && ! $include_failed ) { - $results = wp_cache_get( 'jobs', 'cavalcade-jobs' ); + $args = [ + 'site' => $site, + 'args' => null, + 'statuses' => [ 'waiting', 'running' ], + 'limit' => 0, + '__raw' => true, + ]; + + if ( $include_completed ) { + $args['statuses'][] = 'completed'; + } + if ( $include_failed ) { + $args['statuses'][] = 'failed'; + } + if ( $exclude_future ) { + $args['timestamp'] = 'past'; } - if ( isset( $results ) && ! $results ) { - $statuses = [ 'waiting', 'running' ]; - if ( $include_completed ) { - $statuses[] = 'completed'; - } - if ( $include_failed ) { - $statuses[] = 'failed'; - } + $results = static::get_jobs_by_query( $args ); - // Find all scheduled events for this site - $table = static::get_table(); + if ( empty( $results ) ) { + return []; + } - $sql = "SELECT * FROM `{$table}` WHERE site = %d"; - $sql .= ' AND status IN(' . implode( ',', array_fill( 0, count( $statuses ), '%s' ) ) . ')'; - $query = $wpdb->prepare( $sql, array_merge( [ $site ], $statuses ) ); - $results = $wpdb->get_results( $query ); + return static::to_instances( $results ); + } - if ( ! $include_completed && ! $include_failed ) { - wp_cache_set( 'jobs', $results, 'cavalcade-jobs' ); - } + /** + * Query jobs database. + * + * Returns an array of Job instances for the current site based + * on the paramaters. + * + * @todo: allow searching within time window for duplicate events. + * + * @param array|\stdClass $args { + * @param string $hook Jobs hook to return. Optional. + * @param int|string|null $timestamp Timestamp to search for. Optional. + * String shortcuts `future`: > NOW(); `past`: <= NOW() + * @param array $args Cron job arguments. + * @param int|object $site Site to query. Default current site. + * @param array $statuses Job statuses to query. Default to waiting and running. + * @param int $limit Max number of jobs to return. Default 1. + * @param string $order ASC or DESC. Default ASC. + * } + * @return Job[]|WP_Error Jobs on success, error otherwise. + */ + public static function get_jobs_by_query( $args = [] ) { + global $wpdb; + $args = (array) $args; + $results = []; + + $defaults = [ + 'timestamp' => null, + 'hook' => null, + 'args' => [], + 'site' => get_current_blog_id(), + 'statuses' => [ 'waiting', 'running' ], + 'limit' => 1, + 'order' => 'ASC', + '__raw' => false, + ]; + + $args = wp_parse_args( $args, $defaults ); + + /** + * Filters the get_jobs_by_query() arguments. + * + * An example use case would be to enforce limits on the number of results + * returned if you run into performance problems. + * + * @param array $args { + * @param string $hook Jobs hook to return. Optional. + * @param int|string|array $timestamp Timestamp to search for. Optional. + * String shortcuts `future`: > NOW(); `past`: <= NOW() + * Array of 2 time stamps will search between those dates. + * @param array $args Cron job arguments. + * @param int|object $site Site to query. Default current site. + * @param array $statuses Job statuses to query. Default to waiting and running. + * Possible values are 'waiting', 'running', 'completed' and 'failed'. + * @param int $limit Max number of jobs to return. Default 1. + * @param string $order ASC or DESC. Default ASC. + * @param bool $__raw If true return the raw array of data rather than Job objects. + * } + */ + $args = apply_filters( 'cavalcade.get_jobs_by_query.args', $args ); + + // Allow passing a site object in + if ( is_object( $args['site'] ) && isset( $args['site']->blog_id ) ) { + $args['site'] = $args['site']->blog_id; } - if ( empty( $results ) ) { - return []; + if ( ! is_numeric( $args['site'] ) ) { + return new WP_Error( 'cavalcade.job.invalid_site_id' ); + } + + if ( ! empty( $args['hook'] ) && ! is_string( $args['hook'] ) ) { + return new WP_Error( 'cavalcade.job.invalid_hook_name' ); + } + + if ( ! is_array( $args['args'] ) && ! is_null( $args['args'] ) ) { + return new WP_Error( 'cavalcade.job.invalid_event_arguments' ); + } + + if ( ! is_numeric( $args['limit'] ) ) { + return new WP_Error( 'cavalcade.job.invalid_limit' ); + } + + $args['limit'] = absint( $args['limit'] ); + + // Find all scheduled events for this site + $table = static::get_table(); + + $sql = "SELECT * FROM `{$table}` WHERE site = %d"; + $sql_params[] = $args['site']; + + if ( is_string( $args['hook'] ) ) { + $sql .= ' AND hook = %s'; + $sql_params[] = $args['hook']; + } + + if ( ! is_null( $args['args'] ) ) { + $sql .= ' AND args = %s'; + $sql_params[] = serialize( $args['args'] ); + } + + // Timestamp 'future' shortcut. + if ( $args['timestamp'] === 'future' ) { + $sql .= " AND nextrun > %s"; + $sql_params[] = date( DATE_FORMAT ); + } + + // Timestamp past shortcut. + if ( $args['timestamp'] === 'past' ) { + $sql .= " AND nextrun <= %s"; + $sql_params[] = date( DATE_FORMAT ); + } + + // Timestamp array range. + if ( is_array( $args['timestamp'] ) && count( $args['timestamp'] ) === 2 ) { + $sql .= ' AND nextrun BETWEEN %s AND %s'; + $sql_params[] = date( DATE_FORMAT, (int) $args['timestamp'][0] ); + $sql_params[] = date( DATE_FORMAT, (int) $args['timestamp'][1] ); + } + + // Default integer timestamp. + if ( is_int( $args['timestamp'] ) ) { + $sql .= ' AND nextrun = %s'; + $sql_params[] = date( DATE_FORMAT, (int) $args['timestamp'] ); + } + + $sql .= ' AND status IN(' . implode( ',', array_fill( 0, count( $args['statuses'] ), '%s' ) ) . ')'; + $sql_params = array_merge( $sql_params, $args['statuses'] ); + + $sql .= ' ORDER BY nextrun'; + if ( $args['order'] === 'DESC' ) { + $sql .= ' DESC'; + } else { + $sql .= ' ASC'; + } + + if ( $args['limit'] > 0 ) { + $sql .= ' LIMIT %d'; + $sql_params[] = $args['limit']; + } + + // Cache results. + $last_changed = wp_cache_get_last_changed( 'cavalcade-jobs' ); + $query_hash = sha1( serialize( [ $sql, $sql_params ] ) ) . "::{$last_changed}"; + $found = null; + $results = wp_cache_get( "jobs::{$query_hash}", 'cavalcade-jobs', true, $found ); + + if ( ! $found ) { + $query = $wpdb->prepare( $sql, $sql_params ); + $results = $wpdb->get_results( $query ); + wp_cache_set( "jobs::{$query_hash}", $results, 'cavalcade-jobs' ); + } + + if ( $args['__raw'] === true ) { + return $results; } return static::to_instances( $results ); } + /** + * Invalidates existing query cache keys by updating last changed time. + */ + public static function flush_query_cache() { + wp_cache_set( 'last_changed', microtime(), 'cavalcade-jobs' ); + } + /** * Get the (printf-style) format for a given column. * diff --git a/inc/connector/namespace.php b/inc/connector/namespace.php index 949bad2..dc4f522 100644 --- a/inc/connector/namespace.php +++ b/inc/connector/namespace.php @@ -1,4 +1,7 @@ $event->hook, + 'timestamp' => $event->timestamp, + 'args' => $event->args, + ]; + + if ( $event->schedule === false ) { + // Search ten minute range to test for duplicate events. + if ( $event->timestamp < time() + 10 * MINUTE_IN_SECONDS ) { + $min_timestamp = 0; + } else { + $min_timestamp = $event->timestamp - 10 * MINUTE_IN_SECONDS; + } + + if ( $event->timestamp < time() ) { + $max_timestamp = time() + 10 * MINUTE_IN_SECONDS; + } else { + $max_timestamp = $event->timestamp + 10 * MINUTE_IN_SECONDS; + } + + $query['timestamp'] = [ + $min_timestamp, + $max_timestamp, + ]; + } + + $jobs = Job::get_jobs_by_query( $query ); + if ( is_wp_error( $jobs ) ) { + return false; + } + + // The job does not exist. + if ( empty( $jobs ) ) { + /** This filter is documented in wordpress/wp-includes/cron.php */ + $event = apply_filters( 'schedule_event', $event ); + + // A plugin disallowed this event. + if ( ! $event ) { + return false; + } + + schedule_event( $event ); + return true; + } + + // The job exists. + $existing = $jobs[0]; + + $schedule_match = Cavalcade\get_database_version() >= 2 && $existing->schedule === $event->schedule; + + if ( $schedule_match && $existing->interval === null && ! isset( $event->interval ) ) { + // Unchanged or duplicate single event. + return false; + } elseif ( $schedule_match && $existing->interval === $event->interval ) { + // Unchanged recurring event. + return false; + } else { + // Event has changed. Update it. + if ( Cavalcade\get_database_version() >= 2 ) { + $existing->schedule = $event->schedule; + } + if ( isset( $event->interval ) ) { + $existing->interval = $event->interval; + } else { + $existing->interval = null; + } + $existing->save(); + return true; + } +} + +/** + * Reschedules a recurring event. + * + * Note: The Cavalcade reschedule behaviour is intentionally different to WordPress's. + * To avoid drift of cron schedules, Cavalcade adds the interval to the next scheduled + * run time without checking if this time is in the past. + * + * To ensure the next run time is in the future, it is recommended you delete and reschedule + * a job. + * + * @param null|bool $pre Value to return instead. Default null to continue adding the event. + * @param stdClass $event { + * An object containing an event's data. + * + * @type string $hook Action hook to execute when the event is run. + * @type int $timestamp Unix timestamp (UTC) for when to next run the event. + * @type string|false $schedule How often the event should subsequently recur. + * @type array $args Array containing each separate argument to pass to the hook's callback function. + * @type int $interval The interval time in seconds for the schedule. Only present for recurring events. + * } + * @return bool True if event successfully rescheduled. False for failure. + */ +function pre_reschedule_event( $pre, $event ) { + // Allow other filters to do their thing. + if ( $pre !== null ) { + return $pre; + } + + // First check if the job exists already. + $jobs = Job::get_jobs_by_query( [ + 'hook' => $event->hook, + 'timestamp' => $event->timestamp, + 'args' => $event->args, + ] ); + + if ( is_wp_error( $jobs ) || empty( $jobs ) ) { + // The job does not exist. + return false; + } + + $job = $jobs[0]; + + // Now we assume something is wrong (single job?) and fail to reschedule + if ( 0 === $event->interval && 0 === $job->interval ) { + return false; + } + + $job->nextrun = $job->nextrun + $event->interval; + $job->interval = $event->interval; + $job->schedule = $event->schedule; + $job->save(); + + // Rescheduled. + return true; +} + +/** + * Unschedule a previously scheduled event. + * + * The $timestamp and $hook parameters are required so that the event can be + * identified. + * + * @param null|bool $pre Value to return instead. Default null to continue unscheduling the event. + * @param int $timestamp Timestamp for when to run the event. + * @param string $hook Action hook, the execution of which will be unscheduled. + * @param array $args Arguments to pass to the hook's callback function. + * @return null|bool True if event successfully unscheduled. False for failure. + */ +function pre_unschedule_event( $pre, $timestamp, $hook, $args ) { + // Allow other filters to do their thing. + if ( $pre !== null ) { + return $pre; + } + + // First check if the job exists already. + $jobs = Job::get_jobs_by_query( [ + 'hook' => $hook, + 'timestamp' => $timestamp, + 'args' => $args, + ] ); + + if ( is_wp_error( $jobs ) || empty( $jobs ) ) { + // The job does not exist. + return false; + } + + $job = $jobs[0]; + + // Delete it. + $job->delete(); + + return true; +} + +/** + * Unschedules all events attached to the hook with the specified arguments. + * + * Warning: This function may return Boolean FALSE, but may also return a non-Boolean + * value which evaluates to FALSE. For information about casting to booleans see the + * {@link https://php.net/manual/en/language.types.boolean.php PHP documentation}. Use + * the `===` operator for testing the return value of this function. + * + * @param null|array $pre Value to return instead. Default null to continue unscheduling the event. + * @param string $hook Action hook, the execution of which will be unscheduled. + * @param array|null $args Arguments to pass to the hook's callback function, null to clear all + * events regardless of arugments. + * @return bool|int On success an integer indicating number of events unscheduled (0 indicates no + * events were registered with the hook and arguments combination), false if + * unscheduling one or more events fail. +*/ +function pre_clear_scheduled_hook( $pre, $hook, $args ) { + // Allow other filters to do their thing. + if ( $pre !== null ) { + return $pre; + } + + // First check if the job exists already. + $jobs = Job::get_jobs_by_query( [ + 'hook' => $hook, + 'args' => $args, + 'limit' => 100, + '__raw' => true, + ] ); + + if ( is_wp_error( $jobs ) ) { + return false; + } + + if ( empty( $jobs ) ) { + return 0; + } + + $ids = wp_list_pluck( $jobs, 'id' ); + + global $wpdb; + + // Clear all scheduled events for this site + $table = Job::get_table(); + + $sql = "DELETE FROM `{$table}` WHERE site = %d"; + $sql_params[] = get_current_blog_id(); + + $sql .= ' AND id IN(' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')'; + $sql_params = array_merge( $sql_params, $ids ); + + $query = $wpdb->prepare( $sql, $sql_params ); + $results = $wpdb->query( $query ); + + // Flush the caches. + Job::flush_query_cache(); + foreach ( $ids as $id ) { + wp_cache_delete( "job::{$id}", 'cavalcade-jobs' ); + } + + return $results; +} + +/** + * Unschedules all events attached to the hook. + * + * Can be useful for plugins when deactivating to clean up the cron queue. + * + * Warning: This function may return Boolean FALSE, but may also return a non-Boolean + * value which evaluates to FALSE. For information about casting to booleans see the + * {@link https://php.net/manual/en/language.types.boolean.php PHP documentation}. Use + * the `===` operator for testing the return value of this function. + * + * @param null|array $pre Value to return instead. Default null to continue unscheduling the hook. + * @param string $hook Action hook, the execution of which will be unscheduled. + * @return bool|int On success an integer indicating number of events unscheduled (0 indicates no + * events were registered on the hook), false if unscheduling fails. + */ +function pre_unschedule_hook( $pre, $hook ) { + return pre_clear_scheduled_hook( $pre, $hook, null ); +} + +/** + * Retrieve a scheduled event. + * + * Retrieve the full event object for a given event, if no timestamp is specified the next + * scheduled event is returned. + * + * @param null|bool $pre Value to return instead. Default null to continue retrieving the event. + * @param string $hook Action hook of the event. + * @param array $args Array containing each separate argument to pass to the hook's callback function. + * Although not passed to a callback, these arguments are used to uniquely identify the + * event. + * @param int|null $timestamp Unix timestamp (UTC) of the event. Null to retrieve next scheduled event. + * @return bool|object The event object. False if the event does not exist. + */ +function pre_get_scheduled_event( $pre, $hook, $args, $timestamp ) { + // Allow other filters to do their thing. + if ( $pre !== null ) { + return $pre; + } + + $jobs = Job::get_jobs_by_query( [ + 'hook' => $hook, + 'timestamp' => $timestamp, + 'args' => $args, + ] ); + + if ( is_wp_error( $jobs ) || empty( $jobs ) ) { + return false; + } + + $job = $jobs[0]; + + $value = (object) [ + 'hook' => $job->hook, + 'timestamp' => $job->nextrun, + 'schedule' => $job->schedule, + 'args' => $job->args, + ]; + + if ( isset( $job->interval ) ) { + $value->interval = (int) $job->interval; + } + + return $value; +} + +/** + * Retrieve cron jobs ready to be run. + * + * Returns the results of _get_cron_array() limited to events ready to be run, + * ie, with a timestamp in the past. + * + * @param null|array $pre Array of ready cron tasks to return instead. Default null + * to continue using results from _get_cron_array(). + * @return array Cron jobs ready to be run. + */ +function pre_get_ready_cron_jobs( $pre ) { + // Allow other filters to do their thing. + if ( $pre !== null ) { + return $pre; + } + + $results = Job::get_jobs_by_query( [ + 'timestamp' => 'past', + 'limit' => 100, + ] ); + $crons = []; + + foreach ( $results as $result ) { + $timestamp = $result->nextrun; + $hook = $result->hook; + $key = md5( serialize( $result->args ) ); + $value = (object) [ + 'schedule' => $result->schedule, + 'args' => $result->args, + '_job' => $result, + ]; + + if ( isset( $result->interval ) ) { + $value->interval = (int) $result->interval; + } + + // Build the array up. + if ( ! isset( $crons[ $timestamp ] ) ) { + $crons[ $timestamp ] = []; + } + if ( ! isset( $crons[ $timestamp ][ $hook ] ) ) { + $crons[ $timestamp ][ $hook ] = []; + } + $crons[ $timestamp ][ $hook ][ $key ] = $value; + } + + ksort( $crons, SORT_NUMERIC ); + + return $crons; } /** diff --git a/inc/upgrade/namespace.php b/inc/upgrade/namespace.php index cb9feb3..e0ee7f3 100644 --- a/inc/upgrade/namespace.php +++ b/inc/upgrade/namespace.php @@ -7,6 +7,7 @@ use const HM\Cavalcade\Plugin\DATABASE_VERSION; use HM\Cavalcade\Plugin as Cavalcade; +use HM\Cavalcade\Plugin\Job; /** * Update the Cavalcade database version if required. @@ -34,7 +35,7 @@ function upgrade_database() { update_site_option( 'cavalcade_db_version', DATABASE_VERSION ); - wp_cache_delete( 'jobs', 'cavalcade-jobs' ); + Job::flush_query_cache(); // Upgrade successful. return true; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 7b1410e..ed46d7b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -25,7 +25,7 @@ require $test_root . '/includes/functions.php'; tests_add_filter( 'muplugins_loaded', function () { - require dirname( __DIR__ ) . '/plugin.php'; + require_once dirname( __DIR__ ) . '/plugin.php'; // Call create tables before each run. HM\Cavalcade\Plugin\create_tables(); });