$memory_limit = '32G'; } return Compatibility::convert_hr_to_bytes( $memory_limit ); } /** * Check whether request time limit has been exceeded or not * * Ensures the background job handler never exceeds a sensible time limit. * A timeout limit of 30s is common on shared hosting. * * @since 4.4.0 * * @return bool True, if time limit exceeded, false otherwise */ protected function time_exceeded() { /** * Filter default time limit for background job execution, defaults to * 20 seconds * * @since 4.4.0 * * @param int $time Time in seconds */ $finish = $this->start_time + apply_filters( "{$this->identifier}_default_time_limit", 20 ); $return = false; if ( time() >= $finish ) { $return = true; } /** * Filter whether maximum execution time has exceeded or not * * @since 4.4.0 * @param bool $exceeded true if execution time exceeded, false otherwise */ return apply_filters( "{$this->identifier}_time_exceeded", $return ); } /** * Create a background job * * Delicious Brains' versions alternative would be using ->data()->save(). * Allows passing in any kind of job attributes, which will be available at item data processing time. * This allows sharing common options between items without the need to repeat * the same information for every single item in queue. * * Instead of returning self, returns the job instance, which gives greater * control over the job. * * @since 4.4.0 * * @param array|mixed $attrs Job attributes. * @return \stdClass|object|null */ public function create_job( $attrs ) { global $wpdb; if ( empty( $attrs ) ) { return null; } // generate a unique ID for the job $job_id = md5( microtime() . wp_rand() ); /** * Filter new background job attributes * * @since 4.4.0 * * @param array $attrs Job attributes * @param string $id Job ID */ $attrs = apply_filters( "{$this->identifier}_new_job_attrs", $attrs, $job_id ); // ensure a few must-have attributes $attrs = wp_parse_args( [ 'id' => $job_id, 'created_at' => current_time( 'mysql' ), 'created_by' => get_current_user_id(), 'status' => 'queued', ], $attrs ); $wpdb->insert( $wpdb->options, [ 'option_name' => "{$this->identifier}_job_{$job_id}", 'option_value' => wp_json_encode( $attrs ), 'autoload' => 'no', ] ); $job = new \stdClass(); foreach ( $attrs as $key => $value ) { $job->{$key} = $value; } /** * Runs when a job is created. * * @since 4.4.0 * * @param \stdClass|object $job the created job */ do_action( "{$this->identifier}_job_created", $job ); return $job; } /** * Get a job (by default the first in the queue) * * @since 4.4.0 * * @param string $id Optional. Job ID. Will return first job in queue if not * provided. Will not return completed or failed jobs from queue. * @return \stdClass|object|null The found job object or null */ public function get_job( $id = null ) { global $wpdb; if ( ! $id ) { $key = $this->identifier . '_job_%'; $queued = '%"status":"queued"%'; $processing = '%"status":"processing"%'; $results = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name LIKE %s AND ( option_value LIKE %s OR option_value LIKE %s ) ORDER BY option_id ASC LIMIT 1", $key, $queued, $processing ) ); } else { $results = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", "{$this->identifier}_job_{$id}" ) ); } if ( ! empty( $results ) ) { $job = new \stdClass(); foreach ( json_decode( $results, true ) as $key => $value ) { $job->{$key} = $value; } } else { return null; } /** * Filters the job as returned from the database. * * @since 4.4.0 * * @param \stdClass|object $job */ return apply_filters( "{$this->identifier}_returned_job", $job ); } /** * Gets jobs. * * @since 4.4.2 * * @param array $args { * Optional. An array of arguments * * @type string|array $status Job status(es) to include * @type string $order ASC or DESC. Defaults to DESC * @type string $orderby Field to order by. Defaults to option_id * } * @return \stdClass[]|object[]|null Found jobs or null if none found */ public function get_jobs( $args = [] ) { global $wpdb; $args = wp_parse_args( $args, [ 'order' => 'DESC', 'orderby' => 'option_id', ] ); $replacements = [ $this->identifier . '_job_%' ]; $status_query = ''; // prepare status query if ( ! empty( $args['status'] ) ) { $statuses = (array) $args['status']; $placeholders = []; foreach ( $statuses as $status ) { $placeholders[] = '%s'; $replacements[] = '%"status":"' . sanitize_key( $status ) . '"%'; } $status_query = 'AND ( option_value LIKE ' . implode( ' OR option_value LIKE ', $placeholders ) . ' )'; } // prepare sorting vars $order = sanitize_key( $args['order'] ); $orderby = sanitize_key( $args['orderby'] ); // put it all together now $query = $wpdb->prepare( /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ "SELECT option_value FROM {$wpdb->options} WHERE option_name LIKE %s {$status_query} ORDER BY {$orderby} {$order}", $replacements ); /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared */ $results = $wpdb->get_col( $query ); if ( empty( $results ) ) { return null; } $jobs = []; foreach ( $results as $result ) { $job = new \stdClass(); foreach ( json_decode( $result, true ) as $key => $value ) { $job->{$key} = $value; } /** This filter is documented above */ $job = apply_filters( "{$this->identifier}_returned_job", $job ); $jobs[] = $job; } return $jobs; } /** * Handles jobs. * * Process jobs while remaining within server memory and time limit constraints. * * @since 4.4.0 * * @throws \Exception Upon error. */ protected function handle() { $this->lock_process(); do { // Get next job in the queue $job = $this->get_job(); // handle PHP errors from here on out register_shutdown_function( [ $this, 'handle_shutdown' ], $job ); // Start processing $this->process_job( $job ); } while ( ! $this->time_exceeded() && ! $this->memory_exceeded() && ! $this->is_queue_empty() ); $this->unlock_process(); // Start next job or complete process if ( ! $this->is_queue_empty() ) { $this->dispatch(); } else { $this->complete(); } wp_die(); } /** * Process a job * * Default implementation is to loop over job data and passing each item to * the item processor. Subclasses are, however, welcome to override this method * to create totally different job processing implementations - see * WC_CSV_Import_Suite_Background_Import in CSV Import for an example. * * If using the default implementation, the job must have a $data_key property set. * Subclasses can override the data key, but the contents must be an array which * the job processor can loop over. By default, the data key is `data`. * * If no data is set, the job will completed right away. * * @since 4.4.0 * * @param \stdClass|object $job * @param int $items_per_batch number of items to process in a single request. Defaults to unlimited. * @throws \Exception When job data is incorrect. * @return \stdClass $job */ public function process_job( $job, $items_per_batch = null ) { if ( ! $this->start_time ) { $this->start_time = time(); } // Indicate that the job has started processing if ( 'processing' !== $job->status ) { $job->status = 'processing'; $job->started_processing_at = current_time( 'mysql' ); $job = $this->update_job( $job ); } $data_key = $this->data_key; if ( ! isset( $job->{$data_key} ) ) { /* translators: %s - string representing data key. */ throw new \Exception( sprintf( __( 'Job data key "%s" not set', 'facebook-for-woocommerce' ), $data_key ) ); } if ( ! is_array( $job->{$data_key} ) ) { /* translators: %s - string representing data key. */ throw new \Exception( sprintf( __( 'Job data key "%s" is not an array', 'facebook-for-woocommerce' ), $data_key ) ); } $data = $job->{$data_key}; $job->total = count( $data ); // progress indicates how many items have been processed, it // does NOT indicate the processed item key in any way if ( ! isset( $job->progress ) ) { $job->progress = 0; } // skip already processed items if ( $job->progress && ! empty( $data ) ) { $data = array_slice( $data, $job->progress, null, true ); } // loop over unprocessed items and process them if ( ! empty( $data ) ) { $processed = 0; $items_per_batch = (int) $items_per_batch; foreach ( $data as $item ) { // process the item $this->process_item( $item, $job ); $processed++; $job->progress++; // update job progress $job = $this->update_job( $job ); // job limits reached if ( ( $items_per_batch && $processed >= $items_per_batch ) || $this->time_exceeded() || $this->memory_exceeded() ) { break; } } } // complete current job if ( $job->progress >= count( $job->{$data_key} ) ) { $job = $this->complete_job( $job ); } return $job; } /** * Update job attrs * * @since 4.4.0 * * @param \stdClass|object|string $job Job instance or ID * @return \stdClass|object|false on failure */ public function update_job( $job ) { if ( is_string( $job ) ) { $job = $this->get_job( $job ); } if ( ! $job ) { return false; } $job->updated_at = current_time( 'mysql' ); $this->update_job_option( $job ); /** * Runs when a job is updated. * * @since 4.4.0 * * @param \stdClass|object $job the updated job */ do_action( "{$this->identifier}_job_updated", $job ); return $job; } /** * Handles job completion. * * @since 4.4.0 * * @param \stdClass|object|string $job Job instance or ID * @return \stdClass|object|false on failure */ public function complete_job( $job ) { if ( is_string( $job ) ) { $job = $this->get_job( $job ); } if ( ! $job ) { return false; } $job->status = 'completed'; $job->completed_at = current_time( 'mysql' ); $this->update_job_option( $job ); /** * Runs when a job is completed. * * @since 4.4.0 * * @param \stdClass|object $job the completed job */ do_action( "{$this->identifier}_job_complete", $job ); return $job; } /** * Handle job failure * * Default implementation does not call this method directly, but it's * provided as a convenience method for subclasses that may call this to * indicate that a particular job has failed for some reason. * * @since 4.4.0 * * @param \stdClass|object|string $job Job instance or ID * @param string $reason Optional. Reason for failure. * @return \stdClass|false on failure */ public function fail_job( $job, $reason = '' ) { if ( is_string( $job ) ) { $job = $this->get_job( $job ); } if ( ! $job ) { return false; } $job->status = 'failed'; $job->failed_at = current_time( 'mysql' ); if ( $reason ) { $job->failure_reason = $reason; } $this->update_job_option( $job ); /** * Runs when a job is failed. * * @since 4.4.0 * * @param \stdClass|object $job the failed job */ do_action( "{$this->identifier}_job_failed", $job ); return $job; } /** * Delete a job * * @since 4.4.2 * * @param \stdClass|object|string $job Job instance or ID * @return false on failure */ public function delete_job( $job ) { global $wpdb; if ( is_string( $job ) ) { $job = $this->get_job( $job ); } if ( ! $job ) { return false; } $wpdb->delete( $wpdb->options, [ 'option_name' => "{$this->identifier}_job_{$job->id}" ] ); /** * Runs after a job is deleted. * * @since 4.4.2 * * @param \stdClass|object $job the job that was deleted from database */ do_action( "{$this->identifier}_job_deleted", $job ); } /** * Handle job queue completion * * Override if applicable, but ensure that the below actions are * performed, or, call parent::complete(). * * @since 4.4.0 */ protected function complete() { // unschedule the cron healthcheck $this->clear_scheduled_event(); } /** * Schedule cron healthcheck * * @since 4.4.0 * @param array $schedules * @return array */ public function schedule_cron_healthcheck( $schedules ) { $interval = property_exists( $this, 'cron_interval' ) ? $this->cron_interval : 5; /** * Filter cron health check interval * * @since 4.4.0 * @param int $interval Interval in minutes */ $interval = apply_filters( "{$this->identifier}_cron_interval", $interval ); // adds every 5 minutes to the existing schedules. $schedules[ $this->identifier . '_cron_interval' ] = [ 'interval' => MINUTE_IN_SECONDS * $interval, /* translators: %d - interval in minutes. */ 'display' => sprintf( __( 'Every %d Minutes', 'facebook-for-woocommerce' ), $interval ), ]; return $schedules; } /** * Handle cron healthcheck * * Restart the background process if not already running * and data exists in the queue. * * @since 4.4.0 */ public function handle_cron_healthcheck() { if ( $this->is_process_running() ) { // background process already running exit; } if ( $this->is_queue_empty() ) { // no data to process $this->clear_scheduled_event(); exit; } $this->dispatch(); } /** * Schedule cron health check event * * @since 4.4.0 */ protected function schedule_event() { if ( ! wp_next_scheduled( $this->cron_hook_identifier ) ) { // schedule the health check to fire after 30 seconds from now, as to not create a race condition // with job process lock on servers that fire & handle cron events instantly wp_schedule_event( time() + 30, $this->cron_interval_identifier, $this->cron_hook_identifier ); } } /** * Clear scheduled health check event * * @since 4.4.0 */ protected function clear_scheduled_event() { $timestamp = wp_next_scheduled( $this->cron_hook_identifier ); if ( $timestamp ) { wp_unschedule_event( $timestamp, $this->cron_hook_identifier ); } } /** * Process an item from job data * * Implement this method to perform any actions required on each * item in job data. * * @since 4.4.2 * * @param mixed $item Job data item to iterate over * @param \stdClass|object $job Job instance * @return mixed */ abstract protected function process_item( $item, $job ); /** * Handles PHP shutdown, say after a fatal error. * * @since 4.5.0 * * @param \stdClass|object $job the job being processed */ public function handle_shutdown( $job ) { $error = error_get_last(); // if shutting down because of a fatal error, fail the job if ( $error && E_ERROR === $error['type'] ) { $this->fail_job( $job, $error['message'] ); $this->unlock_process(); } } /** * Update a job option in options database. * * @since 4.6.3 * * @param \stdClass|object $job the job instance to update in database * @return int|bool number of rows updated or false on failure, see wpdb::update() */ private function update_job_option( $job ) { global $wpdb; return $wpdb->update( $wpdb->options, [ 'option_value' => wp_json_encode( $job ) ], [ 'option_name' => "{$this->identifier}_job_{$job->id}" ] ); } /** Debug & Testing Methods ***********************************************/ /** * Tests the background handler's connection. * * @since 4.8.0 * * @return bool */ public function test_connection() { $test_url = add_query_arg( 'action', "{$this->identifier}_test", admin_url( 'admin-ajax.php' ) ); $result = wp_safe_remote_get( $test_url ); $body = ! is_wp_error( $result ) ? wp_remote_retrieve_body( $result ) : null; // some servers may add a UTF8-BOM at the beginning of the response body, so we check if our test // string is included in the body, as an equal check would produce a false negative test result return $body && Helper::str_exists( $body, '[TEST_LOOPBACK]' ); } /** * Handles the connection test request. * * @since 4.8.0 */ public function handle_connection_test_response() { echo '[TEST_LOOPBACK]'; exit; } /** * Adds the WooCommerce debug tool. * * @since 4.8.0 * * @param array $tools WooCommerce core tools * @return array */ public function add_debug_tool( $tools ) { // this key is not unique to the plugin to avoid duplicate tools $tools['sv_wc_background_job_test'] = [ 'name' => __( 'Background Processing Test', 'facebook-for-woocommerce' ), 'button' => __( 'Run Test', 'facebook-for-woocommerce' ), 'desc' => __( 'This tool will test whether your server is capable of processing background jobs.', 'facebook-for-woocommerce' ), 'callback' => [ $this, 'run_debug_tool' ], ]; return $tools; } /** * Runs the test connection debug tool. * * @since 4.8.0 * * @return string */ public function run_debug_tool() { if ( $this->test_connection() ) { $this->debug_message = esc_html__( 'Success! You should be able to process background jobs.', 'facebook-for-woocommerce' ); $result = true; } else { $this->debug_message = esc_html__( 'Could not connect. Please ask your hosting company to ensure your server has loopback connections enabled.', 'facebook-for-woocommerce' ); $result = false; } return $result; } /** * Translate the tool success message. * * This can be removed in favor of returning the message string in `run_debug_tool()` * when WC 3.1 is required, though that means the message will always be "success" styled. * * @since 4.8.0 * * @param string $translated the text to output * @param string $original the original text * @param string $domain the textdomain * @return string the updated text */ public function translate_success_message( $translated, $original, $domain ) { if ( 'woocommerce' === $domain && ( 'Tool ran.' === $original || 'There was an error calling %s' === $original ) ) { $translated = $this->debug_message; } return $translated; } /** Helper Methods ********************************************************/ /** * Gets the job handler identifier. * * @since 4.8.0 * * @return string */ public function get_identifier() { return $this->identifier; } }