pi_client->get_file_contents( $download_url ); } ); if ( is_wp_error( $saved ) ) { $this->handle_failed_job( $item->id, $saved->get_error_code(), $saved->get_error_message(), $saved->get_error_data() ); return false; } // Acknowledge the job completion and delete the item from the queue. $this->api_client->acknowledge_job_completion( $item->job_id ); $this->query->delete_item( $item->id ); return true; } /** * Uploads the next image that's waiting to be uploaded. * * @return bool True if an image was uploaded, false otherwise. */ private function upload_next_waiting_image() : bool { if ( $this->only_image_uploads_postponed ) { // The process is postponed because of reaching the concurrency limit. We cannot proceed. return false; } // Check the limit of concurrent image minification requests pre site. $limit = $this->api_client->get_concurrency_requests_limit(); if ( $limit <= $this->query->get_pending_jobs_count() ) { Manager::debug( 'Cannot upload another image because the site concurrency limit was reached: ' . $limit ); // Postpone optimization by 5 minutes. do_action( 'rocket_image_optimization_postpone', [ 'reason' => 'concurrency_limit', 'severity' => 'warning', 'next_retry_in' => 300, // 5 minutes. ] ); return false; } // Load an item that's ready for upload. $item = $this->query->get_job_ready_to_upload(); // Return false if no item found. if ( is_null( $item ) ) { Manager::debug( 'No minified images ready to be uploaded.' ); return false; } Manager::debug( 'Sending image ' . $item->url . ' in format ' . $item->format . ' to SaaS service.' ); $generated = $this->api_client->send_generation_request( $item->url, $item->format, $item->secret, $this->return_url ); if ( is_wp_error( $generated ) ) { $this->handle_failed_job( $item->id, $generated->get_error_code(), $generated->get_error_message(), $generated->get_error_data() ); return false; } $job_id = $generated->data->id; $this->query->make_status_pending( $item->id, $job_id, [ $this, 'get_wait_time' ] ); return true; } /** * Get wait time. * * @param int $attempt Attempt number. * * @return int Number of seconds to wait before next attempt. * * @phpcs:disable WordPress.WhiteSpace.ControlStructureSpacing.ExtraSpaceAfterCloseParenthesis */ public function get_wait_time( int $attempt ) { return (int) 60 * pow( 2, $attempt ); } /** * Check the status of the next pending image. * * @return bool True if an image was checked, false otherwise. */ private function check_next_pending_image() : bool { // Load the next pending job that is not postponed. $item = $this->query->get_job_pending_not_postponed(); // Return false if no item found. if ( is_null( $item ) ) { Manager::debug( 'No pending images ready to be checked.' ); return false; } if ( $item->retries >= $this->max_retries ) { $this->handle_failed_job( $item->id, 'max_retries', 'Max retries reached' ); return false; } Manager::debug( 'Checking details of job ' . $item->job_id . ' for image ' . $item->url . ' in format ' . $item->format . '.' ); $job_status = $this->api_client->get_job_details( $item->job_id ); if ( is_wp_error( $job_status ) ) { $this->handle_failed_job( $item->id, $job_status->get_error_code(), $job_status->get_error_message(), $job_status->get_error_data() ); return true; } $status_value = $job_status->data->state; if ( in_array( $status_value, [ 'new', 'processing' ], true ) ) { // Job is still pending. $this->query->postpone_item( $item->id, [ $this, 'get_wait_time' ] ); return true; } if ( 'failed' === $status_value ) { // Image minification failed on the SaaS end. $this->handle_failed_job( $item->id, 'job_failed_in_saas', $job_status->data->error ); return true; } if ( 'complete' === $status_value ) { // Image minification is complete, the image is ready to be downloaded. $this->query->make_status_to_download( $item->id ); return true; } return false; } /** * Set failed status. * * @since 3.12.6.1_1.1-2 Added unique_id and wp_job_id to error data. Also added unique_id and feature to the error tags and $data attribute. * * @param int $id row. * @param string $error_code Error code. * @param ?string $error_message Error message. * @param ?mixed $data Error data. * * @return bool * * @phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound */ private function handle_failed_job( int $id, string $error_code, ?string $error_message = '', $data = null ) { $unique_id = $this->options->get_unique_id(); $error_data = [ 'wp_job_id' => $id, 'unique_id' => $unique_id, 'error_code' => $error_code, 'error_message' => $error_message, ]; $job = $this->query->find( $id ); if ( $job ) { if ( ! empty( $job->job_id ) ) { $error_data['job_id'] = $job->job_id; } $error_data['url'] = $job->url; $error_data['format'] = $job->format; $error_data['status'] = $job->status; $error_data['retries'] = $job->retries; } if ( is_array( $data ) && ! empty( $data ) ) { $error_data['error_data'] = $data; } if ( function_exists( 'debug_backtrace' ) ) { if ( ! array_key_exists( 'error_data', $error_data ) ) { $error_data['error_data'] = []; } if ( ! array_key_exists( 'stack_trace', $error_data['error_data'] ) ) { $error_data['error_data']['stack_trace'] = debug_backtrace(); } } do_action( 'accelerate_wp_set_error', E_WARNING, $error_message, __FILE__, __LINE__, $error_data, [ 'feature' => 'image_optimization', 'unique_id' => $unique_id, ] ); Manager::debug( 'Image Optimization job failed: ' . wp_json_encode( $error_data ) ); // Handle a failed job that caused the process to be postponed. if ( did_action( 'rocket_image_optimization_postpone' ) ) { Manager::debug( 'Ignoring job failure because the process was postponed.', $error_data ); if ( 'downloading' === $job->status ) { // Change the status back to "to_download" if the file download failed, but it's possible to try again later. $this->query->make_status_to_download( $id ); Manager::debug( 'Reverted status of job ' . $id . ' back to "to_download".', $error_data ); } return true; } ++$this->process_info['failed']; $this->save_process_info(); return $this->query->make_status_failed( $id, $error_code, $error_message ); } /** * Updates the process item for the next run. * * It also deletes the transient related to process postponing if the current item was successfully processed. This * means that at least one API request was made successfully to the SaaS API. * * @param array $item Background process item. * @param bool $was_successful Whether the action performed with the item was successful. * * @return mixed * * @since 3.12.6.1_1.1-1 */ private function update_item_for_next_run( $item, $was_successful = false ) { // Delete the transient related to process postponing if the current item was successfully processed. if ( $was_successful ) { delete_transient( 'rocket_image_optimization_process_postponed' ); } // Update the execution count. if ( is_array( $item ) && array_key_exists( 'execution_count', $item ) ) { ++$item['execution_count']; } return $item; } /** * {$inheritDoc} */ protected function complete() { if ( $this->stopped_by_error ) { $this->handle_completion_after_stopping_by_error(); } else { $this->handle_successful_completion(); } $this->delete_process_info(); parent::complete(); } /** * Handles the successful completion of the process. */ private function handle_successful_completion() { // Take a note of the time when the process completed. $this->process_info['finished_at'] = current_time( 'mysql', true ); // Move the process info to a separate transient. It will be used to display the results of the process. set_transient( 'rocket_image_optimization_process_completed', $this->process_info ); // Delete transients related to postponing or stopping of the process. delete_transient( 'rocket_image_optimization_process_postponed' ); delete_transient( 'rocket_image_optimization_process_stopped_by_error' ); // Broadcast the completion of the process. if ( $this->process_info['downloaded'] > 0 ) { do_action( 'rocket_image_optimization_complete' ); } } /** * Handles the completion of the process after it was stopped by an error. */ private function handle_completion_after_stopping_by_error() { // Take a note of the time when process was stopped by an error. $this->process_info['stopped_by_error_at'] = current_time( 'mysql', true ); // Add the process info to the stop process data transient. $stop_data = $this->get_stop_process_data(); $stop_data['process_info'] = $this->process_info; set_transient( 'rocket_image_optimization_process_stopped_by_error', $stop_data ); // Delete the transients related to postponing or successful completion of the process. These might be still set from earlier. delete_transient( 'rocket_image_optimization_process_completed' ); delete_transient( 'rocket_image_optimization_process_postponed' ); } /** * Retrieves the data that was set to stop the process. * * @return array */ private function get_stop_process_data() { $result = get_transient( 'rocket_image_optimization_process_stopped_by_error' ); if ( ! is_array( $result ) || ! array_key_exists( 'reason', $result ) ) { return []; } return $result; } /** * Retrieves the data that was set to postpone the process. * * @return array */ private function get_postpone_process_data() { $result = get_transient( 'rocket_image_optimization_process_postponed' ); if ( ! is_array( $result ) || ! array_key_exists( 'reason', $result ) ) { return []; } return $result; } /** * Stops the process if there is a transient set to stop it. * * @param mixed $item Task item. * * @return bool True if the process was stopped, false otherwise. */ private function maybe_stop_process( $item ) { $stop_process_data = $this->get_stop_process_data(); if ( empty( $stop_process_data ) ) { return false; } // Cancel the background process. $this->cancel_process(); // The rest of the cancellation process is done in the complete() method. $this->stopped_by_error = true; Manager::debug( 'The process has been stopped. Reason: ' . $stop_process_data['reason'] ); return true; } /** * Postpones the process if there is a transient set to postpone it. It also checks the timestamp in the dedicated * transient to see if the process can be resumed. * * @param mixed $item Task item. * * @return bool True if the process was postponed, false otherwise. */ private function maybe_postpone_process( $item ) { $postpone_process_data = $this->get_postpone_process_data(); if ( empty( $postpone_process_data ) ) { return false; } $reason = $postpone_process_data['reason']; if ( 'concurrency_limit' === $reason ) { // If the process is supposed to be postponed because of reaching the concurrency limit, we can still allow // the process to make other API calls (check status, download image and acknowledge download). $this->only_image_uploads_postponed = true; $this->postponed = true; return false; } // Check if max number of retries was reached, and if we need to stop the process. $should_stop = false; $retries = $postpone_process_data['retries']; if ( array_key_exists( 'max_retries', $postpone_process_data ) ) { $max_retries = (int) $postpone_process_data['max_retries']; if ( $retries >= $max_retries ) { $should_stop = true; } } if ( $should_stop ) { Manager::debug( 'Maximum number of retries reached. Stopping the process.', $postpone_process_data ); do_action( 'rocket_image_optimization_stop', [ 'reason' => $postpone_process_data['reason'], 'severity' => 'error', ] ); return true; } // Change retry interval for "SaaS service not available" errors after the first hour from 5 minutes to 1 hour. if ( in_array( $postpone_process_data['reason'], [ 'saas_not_available', 'saas_server_error' ], true ) && $postpone_process_data['retries'] >= 12 ) { $postpone_process_data['next_retry_in'] = 3600; // 1 hour } // Calculate the timestamp when the process can be resumed. $postponed_until_timestamp = $this->calculate_next_retry_timestamp( $postpone_process_data ); // Update the number of retries and the last attempt timestamp. It must happen here, otherwise we would never // postpone again in case the timestamp has already passed (delayed or busy WP cron). $now = time(); ++$postpone_process_data['retries']; $postpone_process_data['last_attempt'] = $now; set_transient( 'rocket_image_optimization_process_postponed', $postpone_process_data ); // Check if the timestamp has already passed. if ( $postponed_until_timestamp <= $now ) { Manager::debug( 'Postponing timestamp has already passed. $postponed_until_timestamp: ' . gmdate( 'Y-m-d H:i:s', $postponed_until_timestamp ) . ', $now: ' . gmdate( 'Y-m-d H:i:s', $now ) ); return false; } // Reschedule the process. $this->reschedule_event( $postponed_until_timestamp ); $this->postponed = true; Manager::debug( 'The process has been postponed to ' . gmdate( 'Y-m-d H:i:s', $postponed_until_timestamp ) . '. Reason: ' . $postpone_process_data['reason'] . '.' ); return true; } /** * Reschedules next event to given time. * * @param int $time GMT timestamp of the next event. * * @see parent::schedule_event() */ protected function reschedule_event( $time ) { $this->clear_scheduled_event(); if ( ! wp_next_scheduled( $this->cron_hook_identifier ) ) { wp_schedule_event( $time, $this->cron_interval_identifier, $this->cron_hook_identifier ); } } /** * {@inheritDoc} * * We use this function to prevent the background process from running the task() function again in the same * request. It's done by pretending that the time has been exceeded. */ protected function time_exceeded() { if ( $this->postponed ) { return true; } return parent::time_exceeded(); } /** * {@inheritDoc} * * We overload this function to prevent the background process from triggering the task() function again by * dispatching a loopback request. Without this, the background process library would keep triggering the task() * endlessly because the process $item is not false. */ public function dispatch() { if ( $this->postponed ) { return; } parent::dispatch(); } /** * Checks if the process is postponed. * * Timestamp check can be skipped in case we only want to know if the transient is set. This is useful when we want * check if the process can be recreated. * * @param bool $skip_timestamp_check Whether to skip the timestamp check. * * @return bool */ public function is_process_postponed( $skip_timestamp_check = false ) { // Check if we have a transient that postpones the process. $postpone_process_data = $this->get_postpone_process_data(); if ( empty( $postpone_process_data ) ) { return false; } if ( ! $skip_timestamp_check ) { // Check if the timestamp has already passed. $postponed_until_timestamp = $this->calculate_next_retry_timestamp( $postpone_process_data ); if ( $postponed_until_timestamp <= time() ) { return false; } } return true; } /** * Calculates the timestamp when the process can be resumed. * * @param array $postpone_process_data Postpone process data. * * @return int */ private function calculate_next_retry_timestamp( $postpone_process_data ) { $last_attempt_timestamp = array_key_exists( 'last_attempt', $postpone_process_data ) ? $postpone_process_data['last_attempt'] : $postpone_process_data['created_at']; return $last_attempt_timestamp + $postpone_process_data['next_retry_in']; } /** * Is process running * * Check whether the current process is already running * in a background process. */ public function is_running() { return parent::is_process_running(); } }