diff --git a/.phpcs.xml b/.phpcs.xml index d74d785..c082481 100644 --- a/.phpcs.xml +++ b/.phpcs.xml @@ -36,15 +36,7 @@ - - - - - - - - diff --git a/README.md b/README.md index 73535d2..923e129 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ __Requires PHP 5.6+__ The recommended way to install this library in your project is by loading it through Composer: -``` +```shell composer require deliciousbrains/wp-background-processing ``` @@ -27,6 +27,11 @@ Extend the `WP_Async_Request` class: ```php class WP_Example_Request extends WP_Async_Request { + /** + * @var string + */ + protected $prefix = 'my_plugin'; + /** * @var string */ @@ -45,45 +50,62 @@ class WP_Example_Request extends WP_Async_Request { } ``` -##### `protected $action` +#### `protected $prefix` + +Should be set to a unique prefix associated with your plugin, theme, or site's custom function prefix. + +#### `protected $action` Should be set to a unique name. -##### `protected function handle()` +#### `protected function handle()` Should contain any logic to perform during the non-blocking request. The data passed to the request will be accessible via `$_POST`. -##### Dispatching Requests +#### Dispatching Requests Instantiate your request: -`$this->example_request = new WP_Example_Request();` +```php +$this->example_request = new WP_Example_Request(); +``` Add data to the request if required: -`$this->example_request->data( array( 'value1' => $value1, 'value2' => $value2 ) );` +```php +$this->example_request->data( array( 'value1' => $value1, 'value2' => $value2 ) ); +``` Fire off the request: -`$this->example_request->dispatch();` +```php +$this->example_request->dispatch(); +``` Chaining is also supported: -`$this->example_request->data( array( 'data' => $data ) )->dispatch();` +```php +$this->example_request->data( array( 'data' => $data ) )->dispatch(); +``` ### Background Process -Background processes work in a similar fashion to async requests, but they allow you to queue tasks. Items pushed onto the queue will be processed in the background once the queue has been dispatched. Queues will also scale based on available server resources, so higher end servers will process more items per batch. Once a batch has completed, the next batch will start instantly. +Background processes work in a similar fashion to async requests, but they allow you to queue tasks. Items pushed onto the queue will be processed in the background once the queue has been saved and dispatched. Queues will also scale based on available server resources, so higher end servers will process more items per batch. Once a batch has completed, the next batch will start instantly. Health checks run by default every 5 minutes to ensure the queue is running when queued items exist. If the queue has failed it will be restarted. -Queues work on a first in first out basis, which allows additional items to be pushed to the queue even if it’s already processing. +Queues work on a first in first out basis, which allows additional items to be pushed to the queue even if it’s already processing. Saving a new batch of queued items and dispatching while another background processing instance is already running will result in the dispatch shortcutting out and the existing instance eventually picking up the new items and processing them when it is their turn. Extend the `WP_Background_Process` class: ```php class WP_Example_Process extends WP_Background_Process { + /** + * @var string + */ + protected $prefix = 'my_plugin'; + /** * @var string */ @@ -122,23 +144,29 @@ class WP_Example_Process extends WP_Background_Process { } ``` -##### `protected $action` +#### `protected $prefix` + +Should be set to a unique prefix associated with your plugin, theme, or site's custom function prefix. + +#### `protected $action` Should be set to a unique name. -##### `protected function task( $item )` +#### `protected function task( $item )` Should contain any logic to perform on the queued item. Return `false` to remove the item from the queue or return `$item` to push it back onto the queue for further processing. If the item has been modified and is pushed back onto the queue the current state will be saved before the batch is exited. -##### `protected function complete()` +#### `protected function complete()` Optionally contain any logic to perform once the queue has completed. -##### Dispatching Processes +#### Dispatching Processes Instantiate your process: -`$this->example_process = new WP_Example_Process();` +```php +$this->example_process = new WP_Example_Process(); +``` **Note:** You must instantiate your process unconditionally. All requests should do this, even if nothing is pushed to the queue. @@ -152,7 +180,113 @@ foreach ( $items as $item ) { Save and dispatch the queue: -`$this->example_process->save()->dispatch();` +```php +$this->example_process->save()->dispatch(); +``` + +#### Background Process Status + +A background process can be queued, processing, paused, cancelled, or none of the above (not started or has completed). + +##### Queued + +To check whether a background process has queued items use `is_queued()`. + +```php +if ( $this->example_process->is_queued() ) { + // Do something because background process has queued items, e.g. add notice in admin UI. +} +``` + +##### Processing + +To check whether a background process is currently handling a queue of items use `is_processing()`. + +```php +if ( $this->example_process->is_processing() ) { + // Do something because background process is running, e.g. add notice in admin UI. +} +``` + +##### Paused + +You can pause a background process with `pause()`. + +```php +$this->example_process->pause(); +``` + +The currently processing batch will continue until it either completes or reaches the time or memory limit. At that point it'll unlock the process and either complete the batch if the queue is empty, or perform a dispatch that will result in the handler removing the healthcheck cron and firing a "paused" action. + +To check whether a background process is currently paused use `is_paused()`. + +```php +if ( $this->example_process->is_paused() ) { + // Do something because background process is paused, e.g. add notice in admin UI. +} +``` + +You can perform an action in response to background processing being paused by handling the "paused" action for the background process's identifier ($prefix + $action). + +```php +add_action( 'my_plugin_example_process_paused', function() { + // Do something because background process is paused, e.g. add notice in admin UI. +}); +``` + +You can resume a background process with `resume()`. + +```php +$this->example_process->resume(); +``` + +You can perform an action in response to background processing being resumed by handling the "resumed" action for the background process's identifier ($prefix + $action). + +```php +add_action( 'my_plugin_example_process_resumed', function() { + // Do something because background process is resumed, e.g. add notice in admin UI. +}); +``` + +##### Cancelled + +You can cancel a background process with `cancel()`. + +```php +$this->example_process->cancel(); +``` + +The currently processing batch will continue until it either completes or reaches the time or memory limit. At that point it'll unlock the process and either complete the batch if the queue is empty, or perform a dispatch that will result in the handler removing the healthcheck cron, deleting all batches of queued items and firing a "cancelled" action. + +To check whether a background process is currently cancelled use `is_cancelled()`. + +```php +if ( $this->example_process->is_cancelled() ) { + // Do something because background process is cancelled, e.g. add notice in admin UI. +} +``` + +You can perform an action in response to background processing being cancelled by handling the "cancelled" action for the background process's identifier ($prefix + $action). + +```php +add_action( 'my_plugin_example_process_cancelled', function() { + // Do something because background process is paused, e.g. add notice in admin UI. +}); +``` + +The "cancelled" action fires once the queue has been cleared down and cancelled status removed. After which `is_cancelled()` will no longer be true as the background process is now dormant. + +##### Active + +To check whether a background process has queued items, is processing, is paused, or is cancelling, use `is_active()`. + +```php +if ( $this->example_process->is_active() ) { + // Do something because background process is active, e.g. add notice in admin UI. +} +``` + +If a background process is not active, then it either has not had anything queued yet and not started, or has finished processing all queued items. ### BasicAuth diff --git a/classes/wp-async-request.php b/classes/wp-async-request.php index 595788b..9759ab0 100644 --- a/classes/wp-async-request.php +++ b/classes/wp-async-request.php @@ -142,8 +142,8 @@ protected function get_post_args() { 'timeout' => 0.01, '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. ); /** @@ -158,6 +158,8 @@ protected function get_post_args() { * 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. @@ -167,7 +169,27 @@ public function maybe_handle() { $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 + */ + 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; } /** diff --git a/classes/wp-background-process.php b/classes/wp-background-process.php index 9af3a40..82a8e63 100644 --- a/classes/wp-background-process.php +++ b/classes/wp-background-process.php @@ -36,7 +36,7 @@ abstract class WP_Background_Process extends WP_Async_Request { /** * Cron_hook_identifier * - * @var mixed + * @var string * @access protected */ protected $cron_hook_identifier; @@ -44,11 +44,25 @@ abstract class WP_Background_Process extends WP_Async_Request { /** * Cron_interval_identifier * - * @var mixed + * @var string * @access protected */ protected $cron_interval_identifier; + /** + * 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. */ @@ -69,7 +83,7 @@ public function __construct() { * @return array|WP_Error|false HTTP Response array, WP_Error on failure, or false if not attempted. */ public function dispatch() { - if ( $this->is_process_running() ) { + if ( $this->is_processing() ) { // Process already running. return false; } @@ -108,6 +122,9 @@ 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; } @@ -140,23 +157,145 @@ public function delete( $key ) { return $this; } + /** + * 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 ); + + if ( absint( $status ) === self::STATUS_CANCELLED ) { + return true; + } + + return false; + } + + /** + * 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 ); + + if ( absint( $status ) === self::STATUS_PAUSED ) { + return true; + } + + return false; + } + + /** + * 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 ); } + /** + * Get the status key. + * + * @return string + */ + protected function get_status_key() { + return $this->identifier . '_status'; + } + /** * Maybe process a batch of queued items. * @@ -167,21 +306,35 @@ public function maybe_handle() { // 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(); } /** @@ -190,25 +343,7 @@ public function maybe_handle() { * @return bool */ 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 ); + return empty( $this->get_batch() ); } /** @@ -216,8 +351,22 @@ protected function is_queue_empty() { * * Check whether the current process is already running * in a background process. + * + * @return bool + * + * @deprecated 1.1.0 Superseded. + * @see is_processing() */ 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; @@ -261,8 +410,29 @@ protected function unlock_process() { * @return stdClass Return the first batch of queued items. */ protected function get_batch() { + return array_reduce( + $this->get_batches( 1 ), + 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'; @@ -277,19 +447,39 @@ 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 . ' ASC + '; - $batch = new stdClass(); - $batch->key = $query->{$column}; - $batch->data = maybe_unserialize( $query->{$value_column} ); + $args = array( $key ); - return $batch; + if ( ! empty( $limit ) ) { + $sql .= ' LIMIT %d'; + + $args[] = $limit; + } + + $items = $wpdb->get_results( $wpdb->prepare( $sql, $args ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + $batches = array(); + + if ( ! empty( $items ) ) { + $batches = array_map( + 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; } /** @@ -301,6 +491,22 @@ protected function get_batch() { 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(); @@ -313,16 +519,22 @@ protected function handle() { unset( $batch->data[ $key ] ); } + // 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 ); + if ( $this->time_exceeded() || $this->memory_exceeded() ) { // Batch limits reached. 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() ); @@ -336,7 +548,7 @@ protected function handle() { $this->complete(); } - wp_die(); + return $this->maybe_wp_die(); } /** @@ -406,8 +618,19 @@ protected function time_exceeded() { * performed, or, call parent::complete(). */ protected function complete() { + 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' ); } /** @@ -420,16 +643,22 @@ 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 an "Every NNN Minutes" schedule to the existing cron 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; @@ -442,7 +671,7 @@ public function schedule_cron_healthcheck( $schedules ) { * 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; } @@ -453,9 +682,7 @@ public function handle_cron_healthcheck() { exit; } - $this->handle(); - - exit; + $this->dispatch(); } /** @@ -483,15 +710,11 @@ protected function clear_scheduled_event() { * * Stop processing queue items, clear cron job and delete batch. * + * @deprecated 1.1.0 Superseded. + * @see cancel() */ 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(); } /** diff --git a/tests/Test_WP_Background_Process.php b/tests/Test_WP_Background_Process.php index f499abe..19926e9 100644 --- a/tests/Test_WP_Background_Process.php +++ b/tests/Test_WP_Background_Process.php @@ -51,6 +51,25 @@ private function getWPBPProperty( string $name ) { return $property->getValue( $this->wpbp ); } + /** + * Execute a method of WPBP regardless of accessibility. + * + * @param string $name Method name. + * @param mixed $args None, one or more args to pass to method. + * + * @return mixed + */ + private function executeWPBPMethod( string $name, ...$args ) { + try { + $method = new ReflectionMethod( 'WP_Background_Process', $name ); + $method->setAccessible( true ); + + return $method->invoke( $this->wpbp, ...$args ); + } catch ( Exception $e ) { + return new WP_Error( $e->getCode(), $e->getMessage() ); + } + } + /** * Test push_to_queue. * @@ -67,4 +86,504 @@ public function test_push_to_queue() { $this->wpbp->push_to_queue( 'wobble' ); $this->assertEquals( array( 'wibble', 'wobble' ), $this->getWPBPProperty( 'data' ) ); } + + /** + * Test save. + * + * @return void + */ + public function test_save() { + $this->assertClassHasAttribute( 'data', 'WP_Background_Process', 'class has data property' ); + $this->assertEmpty( $this->getWPBPProperty( 'data' ) ); + $this->assertEmpty( $this->wpbp->get_batches(), 'no batches until save' ); + + $this->wpbp->push_to_queue( 'wibble' ); + $this->assertNotEmpty( $this->getWPBPProperty( 'data' ) ); + $this->assertEquals( array( 'wibble' ), $this->getWPBPProperty( 'data' ) ); + $this->wpbp->save(); + $this->assertEmpty( $this->getWPBPProperty( 'data' ), 'data emptied after save' ); + $this->assertNotEmpty( $this->wpbp->get_batches(), 'batches exist after save' ); + } + + /** + * Test get_batches. + * + * @return void + */ + public function test_get_batches() { + $this->assertEmpty( $this->wpbp->get_batches(), 'no batches until save' ); + + $this->wpbp->push_to_queue( 'wibble' ); + $this->assertNotEmpty( $this->getWPBPProperty( 'data' ) ); + $this->assertEquals( array( 'wibble' ), $this->getWPBPProperty( 'data' ) ); + $this->assertEmpty( $this->wpbp->get_batches(), 'no batches until save' ); + + $this->wpbp->push_to_queue( 'wobble' ); + $this->assertEquals( array( 'wibble', 'wobble' ), $this->getWPBPProperty( 'data' ) ); + $this->assertEmpty( $this->wpbp->get_batches(), 'no batches until save' ); + + $this->wpbp->save(); + $first_batch = $this->wpbp->get_batches(); + $this->assertNotEmpty( $first_batch ); + $this->assertCount( 1, $first_batch ); + + $this->wpbp->push_to_queue( 'more wibble' ); + $this->wpbp->save(); + $this->assertCount( 2, $this->wpbp->get_batches() ); + + $this->wpbp->push_to_queue( 'Wibble wobble all day long.' ); + $this->wpbp->save(); + $this->assertCount( 3, $this->wpbp->get_batches() ); + + $this->assertEquals( $first_batch, $this->wpbp->get_batches( 1 ) ); + $this->assertNotEquals( $first_batch, $this->wpbp->get_batches( 2 ) ); + $this->assertCount( 2, $this->wpbp->get_batches( 2 ) ); + $this->assertCount( 3, $this->wpbp->get_batches( 3 ) ); + $this->assertCount( 3, $this->wpbp->get_batches( 5 ) ); + } + + /** + * Test get_batch. + * + * @return void + */ + public function test_get_batch() { + $this->assertEmpty( $this->executeWPBPMethod( 'get_batch' ), 'no batches until save' ); + + $this->wpbp->push_to_queue( 'wibble' ); + $this->assertNotEmpty( $this->getWPBPProperty( 'data' ) ); + $this->assertEquals( array( 'wibble' ), $this->getWPBPProperty( 'data' ) ); + $this->assertEmpty( $this->executeWPBPMethod( 'get_batch' ), 'no batches until save' ); + + $this->wpbp->push_to_queue( 'wobble' ); + $this->assertEquals( array( 'wibble', 'wobble' ), $this->getWPBPProperty( 'data' ) ); + $this->assertEmpty( $this->executeWPBPMethod( 'get_batch' ), 'no batches until save' ); + + $this->wpbp->save(); + $first_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->assertNotEmpty( $first_batch ); + $this->assertInstanceOf( 'stdClass', $first_batch ); + $this->assertEquals( array( 'wibble', 'wobble' ), $first_batch->data ); + + $this->wpbp->push_to_queue( 'more wibble' ); + $this->wpbp->save(); + $second_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->assertNotEmpty( $second_batch ); + $this->assertInstanceOf( 'stdClass', $second_batch ); + $this->assertEquals( $first_batch, $second_batch, 'same 1st batch returned until deleted' ); + + $this->wpbp->delete( $first_batch->key ); + $second_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->assertNotEmpty( $second_batch ); + $this->assertInstanceOf( 'stdClass', $second_batch ); + $this->assertNotEquals( $first_batch, $second_batch, '2nd batch returned as 1st deleted' ); + $this->assertEquals( array( 'more wibble' ), $second_batch->data ); + } + + /** + * Test cancel. + * + * @return void + */ + public function test_cancel() { + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertFalse( $this->wpbp->is_cancelled() ); + $this->wpbp->cancel(); + $this->assertTrue( $this->wpbp->is_cancelled() ); + } + + /** + * Test pause. + * + * @return void + */ + public function test_pause() { + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertFalse( $this->wpbp->is_paused() ); + $this->wpbp->pause(); + $this->assertTrue( $this->wpbp->is_paused() ); + } + + /** + * Test resume. + * + * @return void + */ + public function test_resume() { + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertFalse( $this->wpbp->is_paused() ); + $this->wpbp->pause(); + $this->assertTrue( $this->wpbp->is_paused() ); + $this->wpbp->resume(); + $this->assertFalse( $this->wpbp->is_paused() ); + } + + /** + * Test delete. + * + * @return void + */ + public function test_delete() { + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $this->wpbp->push_to_queue( 'wobble' ); + $this->wpbp->save(); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $first_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->wpbp->delete( $first_batch->key ); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $second_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->assertNotEquals( $first_batch, $second_batch, '2nd batch returned as 1st deleted' ); + } + + /** + * Test delete_all. + * + * @return void + */ + public function test_delete_all() { + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $this->wpbp->push_to_queue( 'wobble' ); + $this->wpbp->save(); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $this->wpbp->delete_all(); + $this->assertCount( 0, $this->wpbp->get_batches() ); + } + + /** + * Test update. + * + * @return void + */ + public function test_update() { + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $this->wpbp->push_to_queue( 'wobble' ); + $this->wpbp->save(); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $first_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->wpbp->update( $first_batch->key, array( 'Wibble wobble all day long!' ) ); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $updated_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->assertNotEquals( $first_batch, $updated_batch, 'fetched updated batch different to 1st fetch' ); + $this->assertEquals( array( 'Wibble wobble all day long!' ), $updated_batch->data, 'fetched updated batch has expected data' ); + } + + /** + * Test maybe_handle when cancelling. + * + * @return void + */ + public function test_maybe_handle_cancelled() { + // Cancelled status results in cleared batches and action fired. + $cancelled_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_cancelled', function () use ( &$cancelled_fired ) { + $cancelled_fired = true; + } ); + // Paused action should not be fired though. + $paused_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_paused', function () use ( &$paused_fired ) { + $paused_fired = true; + } ); + // Completed action should not be fired though. + $completed_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_completed', function () use ( &$completed_fired ) { + $completed_fired = true; + } ); + add_filter( $this->getWPBPProperty( 'identifier' ) . '_wp_die', '__return_false' ); + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $this->wpbp->push_to_queue( 'wobble' ); + $this->wpbp->save(); + $this->assertCount( 2, $this->wpbp->get_batches() ); + update_site_option( $this->executeWPBPMethod( 'get_status_key' ), WP_Background_Process::STATUS_CANCELLED ); + $this->assertTrue( $this->wpbp->is_cancelled(), 'is_cancelled' ); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $this->assertFalse( $cancelled_fired, 'cancelled action not fired yet' ); + $this->assertFalse( $paused_fired, 'paused action not fired yet' ); + $this->assertFalse( $completed_fired, 'completed action not fired yet' ); + $this->wpbp->maybe_handle(); + $this->assertCount( 0, $this->wpbp->get_batches() ); + $this->assertTrue( $cancelled_fired, 'cancelled action fired' ); + $this->assertFalse( $paused_fired, 'paused action still not fired yet' ); + $this->assertFalse( $completed_fired, 'completed action not fired yet' ); + } + + /** + * Test maybe_handle when pausing and resuming. + * + * @return void + */ + public function test_maybe_handle_paused_resumed() { + // Cancelled action should not be fired. + $cancelled_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_cancelled', function () use ( &$cancelled_fired ) { + $cancelled_fired = true; + } ); + // Paused action should fire and batches remain intact. + $paused_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_paused', function () use ( &$paused_fired ) { + $paused_fired = true; + } ); + // Resumed action should fire on resume before batches handled. + $resumed_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_resumed', function () use ( &$resumed_fired ) { + $resumed_fired = true; + } ); + // Completed action should fire after batches handled. + $completed_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_completed', function () use ( &$completed_fired ) { + $completed_fired = true; + } ); + add_filter( $this->getWPBPProperty( 'identifier' ) . '_wp_die', '__return_false' ); + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $this->wpbp->push_to_queue( 'wobble' ); + $this->wpbp->save(); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $this->wpbp->pause(); + $this->assertTrue( $this->wpbp->is_paused(), 'is_paused' ); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $this->assertFalse( $cancelled_fired, 'cancelled action not fired yet' ); + $this->assertFalse( $paused_fired, 'paused action not fired yet' ); + $this->assertFalse( $resumed_fired, 'resumed action not fired yet' ); + $this->assertFalse( $completed_fired, 'completed action not fired yet' ); + $this->wpbp->maybe_handle(); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $this->assertFalse( $cancelled_fired, 'cancelled action still not fired yet' ); + $this->assertTrue( $paused_fired, 'paused action fired' ); + $this->assertFalse( $resumed_fired, 'resumed action still not fired yet' ); + $this->assertFalse( $completed_fired, 'completed action not fired yet' ); + + // Reset for resume and ensure dispatch does nothing to that maybe_handle can be monitored. + $paused_fired = false; + add_filter( 'pre_http_request', '__return_true' ); + $this->wpbp->resume(); + remove_filter( 'pre_http_request', '__return_true' ); + $this->assertFalse( $this->wpbp->is_paused(), 'not is_paused after resume' ); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $this->assertFalse( $cancelled_fired, 'cancelled action not fired yet' ); + $this->assertFalse( $paused_fired, 'paused action not fired yet' ); + $this->assertTrue( $resumed_fired, 'resumed action fired' ); + $this->assertFalse( $completed_fired, 'completed action not fired yet' ); + + // Don't expect resumed to be fired again, and batches to be handled with valid security. + $resumed_fired = false; + $_REQUEST['nonce'] = wp_create_nonce( $this->getWPBPProperty( 'identifier' ) ); + $this->wpbp->maybe_handle(); + $this->assertCount( 0, $this->wpbp->get_batches(), 'after resume all batches processed with maybe_handle' ); + $this->assertFalse( $cancelled_fired, 'cancelled action still not fired yet' ); + $this->assertFalse( $paused_fired, 'paused action not fired yet' ); + $this->assertFalse( $resumed_fired, 'resumed action still not fired yet' ); + $this->assertTrue( $completed_fired, 'completed action fired' ); + } + + /** + * Test maybe_handle when handling a single batch. + * + * @return void + */ + public function test_maybe_handle_single_batch() { + // Cancelled action should not be fired. + $cancelled_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_cancelled', function () use ( &$cancelled_fired ) { + $cancelled_fired = true; + } ); + // Paused action should not be fired. + $paused_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_paused', function () use ( &$paused_fired ) { + $paused_fired = true; + } ); + // Resumed action should not be fired. + $resumed_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_resumed', function () use ( &$resumed_fired ) { + $resumed_fired = true; + } ); + // Completed action should fire after batches handled. + $completed_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_completed', function () use ( &$completed_fired ) { + $completed_fired = true; + } ); + add_filter( $this->getWPBPProperty( 'identifier' ) . '_wp_die', '__return_false' ); + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $this->assertFalse( $cancelled_fired, 'cancelled action not fired yet' ); + $this->assertFalse( $paused_fired, 'paused action not fired yet' ); + $this->assertFalse( $resumed_fired, 'resumed action not fired yet' ); + $this->assertFalse( $completed_fired, 'completed action not fired yet' ); + + $_REQUEST['nonce'] = wp_create_nonce( $this->getWPBPProperty( 'identifier' ) ); + $this->wpbp->maybe_handle(); + $this->assertCount( 0, $this->wpbp->get_batches(), 'after resume all batches processed with maybe_handle' ); + $this->assertFalse( $cancelled_fired, 'cancelled action still not fired yet' ); + $this->assertFalse( $paused_fired, 'paused action not fired yet' ); + $this->assertFalse( $resumed_fired, 'resumed action still not fired yet' ); + $this->assertTrue( $completed_fired, 'completed action fired' ); + } + + /** + * Test maybe_handle when handling nothing. + * + * @return void + */ + public function test_maybe_handle_nothing() { + // Cancelled action should not be fired. + $cancelled_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_cancelled', function () use ( &$cancelled_fired ) { + $cancelled_fired = true; + } ); + // Paused action should not be fired. + $paused_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_paused', function () use ( &$paused_fired ) { + $paused_fired = true; + } ); + // Resumed action should not be fired. + $resumed_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_resumed', function () use ( &$resumed_fired ) { + $resumed_fired = true; + } ); + // Completed action should not be fired. + $completed_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_completed', function () use ( &$completed_fired ) { + $completed_fired = true; + } ); + add_filter( $this->getWPBPProperty( 'identifier' ) . '_wp_die', '__return_false' ); + $this->assertCount( 0, $this->wpbp->get_batches() ); + $this->assertFalse( $cancelled_fired, 'cancelled action not fired yet' ); + $this->assertFalse( $paused_fired, 'paused action not fired yet' ); + $this->assertFalse( $resumed_fired, 'resumed action not fired yet' ); + $this->assertFalse( $completed_fired, 'completed action not fired yet' ); + + $this->wpbp->maybe_handle(); + $this->assertCount( 0, $this->wpbp->get_batches(), 'after resume all batches processed with maybe_handle' ); + $this->assertFalse( $cancelled_fired, 'cancelled action still not fired yet' ); + $this->assertFalse( $paused_fired, 'paused action not fired yet' ); + $this->assertFalse( $resumed_fired, 'resumed action still not fired yet' ); + $this->assertFalse( $completed_fired, 'completed action not fired yet' ); + } + + /** + * Test is_processing. + * + * @return void + */ + public function test_is_processing() { + $this->assertFalse( $this->wpbp->is_processing(), 'not processing yet' ); + $this->executeWPBPMethod( 'lock_process' ); + $this->assertTrue( $this->wpbp->is_processing(), 'processing' ); + + // With batches to be processed, maybe_handle does nothing as "another instance is processing". + add_filter( $this->getWPBPProperty( 'identifier' ) . '_wp_die', '__return_false' ); + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $this->wpbp->maybe_handle(); + $this->assertCount( 1, $this->wpbp->get_batches() ); + + // Unlock and maybe_handle can process the batch. + $this->executeWPBPMethod( 'unlock_process' ); + $this->assertFalse( $this->wpbp->is_processing(), 'not processing yet' ); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $_REQUEST['nonce'] = wp_create_nonce( $this->getWPBPProperty( 'identifier' ) ); + $this->wpbp->maybe_handle(); + $this->assertCount( 0, $this->wpbp->get_batches() ); + $this->assertFalse( $this->wpbp->is_processing(), 'not left processing on complete' ); + } + + /** + * Test is_queued. + * + * @return void + */ + public function test_is_queued() { + $this->assertFalse( $this->wpbp->is_queued(), 'nothing queued until save' ); + + $this->wpbp->push_to_queue( 'wibble' ); + $this->assertFalse( $this->wpbp->is_queued(), 'nothing queued until save' ); + + $this->wpbp->save(); + $this->assertTrue( $this->wpbp->is_queued(), 'queued items exist' ); + + $this->wpbp->push_to_queue( 'wobble' ); + $this->wpbp->save(); + $this->assertTrue( $this->wpbp->is_queued(), 'queued items exist' ); + + $this->wpbp->delete_all(); + $this->assertFalse( $this->wpbp->is_queued(), 'queue emptied' ); + } + + /** + * Test is_active. + * + * @return void + */ + public function test_is_active() { + $this->assertFalse( $this->wpbp->is_active(), 'not queued, processing, paused or cancelling' ); + + // Queued. + $this->wpbp->push_to_queue( 'wibble' ); + $this->assertFalse( $this->wpbp->is_active(), 'nothing queued until save' ); + + $this->wpbp->save(); + $this->assertTrue( $this->wpbp->is_active(), 'queued items exist, so now active' ); + + $this->wpbp->delete_all(); + $this->assertFalse( $this->wpbp->is_active(), 'queue emptied, so no longer active' ); + + // Processing. + $this->executeWPBPMethod( 'lock_process' ); + $this->assertTrue( $this->wpbp->is_active(), 'processing, so now active' ); + + $this->executeWPBPMethod( 'unlock_process' ); + $this->assertFalse( $this->wpbp->is_active(), 'not processing, so no longer active' ); + + // Paused. + $this->wpbp->pause(); + $this->assertTrue( $this->wpbp->is_active(), 'paused, so now active' ); + + $this->wpbp->resume(); + $this->assertFalse( $this->wpbp->is_active(), 'not paused, nothing queued, so no longer active' ); + + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertTrue( $this->wpbp->is_active(), 'queued items exist, so now active' ); + $this->wpbp->pause(); + $this->assertTrue( $this->wpbp->is_active(), 'paused, so still active' ); + add_filter( 'pre_http_request', '__return_true' ); + $this->wpbp->resume(); + remove_filter( 'pre_http_request', '__return_true' ); + $this->assertTrue( $this->wpbp->is_active(), 'resumed but with queued items, so still active' ); + $this->wpbp->delete_all(); + $this->assertFalse( $this->wpbp->is_active(), 'queue emptied, so no longer active' ); + + // Cancelled. + add_filter( 'pre_http_request', '__return_true' ); + $this->wpbp->cancel(); + remove_filter( 'pre_http_request', '__return_true' ); + $this->assertTrue( $this->wpbp->is_active(), 'cancelling, so now active' ); + + add_filter( $this->getWPBPProperty( 'identifier' ) . '_wp_die', '__return_false' ); + $this->wpbp->maybe_handle(); + $this->assertFalse( $this->wpbp->is_active(), 'cancel handled, so no longer active' ); + + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertTrue( $this->wpbp->is_active(), 'queued items exist, so now active' ); + add_filter( 'pre_http_request', '__return_true' ); + $this->wpbp->cancel(); + remove_filter( 'pre_http_request', '__return_true' ); + $this->assertTrue( $this->wpbp->is_active(), 'cancelling, so still active' ); + $this->wpbp->maybe_handle(); + $this->assertFalse( $this->wpbp->is_active(), 'cancel handled, queue emptied, so no longer active' ); + } }