diff --git a/includes/RapidLoad_Api.php b/includes/RapidLoad_Api.php index 27103bc13..ae7f29a76 100644 --- a/includes/RapidLoad_Api.php +++ b/includes/RapidLoad_Api.php @@ -11,6 +11,11 @@ class RapidLoad_Api public $apiKey = null; + /** + * Retry-After (seconds) of the last response, 0 when absent. + */ + public $retry_after = 0; + /** * RapidLoad_Api constructor. @@ -73,8 +78,12 @@ function post( $endpoint, $data = [] ) { */ public function handle_response( $response , $data = null) { + $this->retry_after = 0; + if ( is_array( $response ) && ! is_wp_error( $response ) ) { + $this->retry_after = max( 0, (int) wp_remote_retrieve_header( $response, 'retry-after' ) ); + if ( $response['response']['code'] === 200 ) { $body = $response['body']; diff --git a/includes/RapidLoad_DB.php b/includes/RapidLoad_DB.php index 3f4d4961b..eb93d3cc1 100644 --- a/includes/RapidLoad_DB.php +++ b/includes/RapidLoad_DB.php @@ -656,7 +656,7 @@ public static function transform_link($link, $rule = 'path'){ $data['applied_successful_links'] = isset( $link->applied_successful_links ) ? $link->applied_successful_links : 0; $data['applied_links'] = isset( $link->applied_successful_links ) ? $link->applied_successful_links : 0; $data['time'] = isset( $link->created_at ) ? strtotime( $link->created_at ) * 1000 : null; - $data['status'] = isset( $link->status ) ? $link->status : null; + $data['status'] = isset( $link->status ) ? RapidLoad_Job_Data::display_status($link->status) : null; if(isset($data['rule_id'])){ $job_url = RapidLoad_Job::find_or_fail($data['rule_id']); $data['base'] = $job_url->url; @@ -689,7 +689,7 @@ public static function requeueJob($id){ global $wpdb; - $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s WHERE job_id = %d", 'queued', $id ) ); + $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, attempts = 0, created_at = %s WHERE job_id = %d AND status <> 'submitting'", 'queued', gmdate( 'Y-m-d H:i:s' ), $id ) ); $error = $wpdb->last_error; @@ -705,22 +705,42 @@ public static function updateUrlJobDataStatusWhere($status = 'queued', $ids = [] global $wpdb; if (!empty($ids)) { - $ids = implode(",", array_map('intval', $ids)); + $ids = array_map('intval', (array) $ids); } else { - $ids = $wpdb->get_col($wpdb->prepare("SELECT id FROM {$wpdb->prefix}rapidload_job WHERE rule = %s", 'is_url')); - $ids = implode(',', array_map('intval', $ids)); + $ids = array_map('intval', $wpdb->get_col($wpdb->prepare("SELECT id FROM {$wpdb->prefix}rapidload_job WHERE rule = %s", 'is_url'))); } - if (empty($wherStatus)) { - $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s WHERE job_id IN (%d)", $status, $ids)); - } else { - if($wherStatus === "success"){ - $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s WHERE job_id IN (%d) AND status = %s AND warnings IS NOT NULL", $status, $ids, $wherStatus)); - }else{ - $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s WHERE job_id IN (%d) AND status = %s", $status, $ids, $wherStatus)); + if (empty($ids)) { + return true; + } + + // explicit requeue: clear retry state and any backoff + $set = 'status = %s'; + $args = [ $status ]; + if ($status === 'queued') { + $set .= ', attempts = 0, created_at = %s'; + $args[] = gmdate( 'Y-m-d H:i:s' ); + } + + $where = 'job_id IN (' . implode(',', array_fill(0, count($ids), '%d')) . ')'; + $args = array_merge($args, $ids); + + if ($status === 'queued') { + // rows mid-submit keep their claim; the in-flight result lands normally + $where .= " AND status <> 'submitting'"; + } + + if (!empty($wherStatus)) { + $where .= ' AND status = %s'; + $args[] = $wherStatus; + if ($wherStatus === "success") { + $where .= ' AND warnings IS NOT NULL'; } } + // $set and $where are built only from fixed fragments and %s/%d placeholders + $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}rapidload_job_data SET $set WHERE $where", $args ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared + $error = $wpdb->last_error; if(!empty($error)){ @@ -735,22 +755,42 @@ public static function updateRuleJobDataStatusWhere($status = 'queued', $ids = [ global $wpdb; if (!empty($ids)) { - $ids = implode(",", array_map('intval', $ids)); + $ids = array_map('intval', (array) $ids); } else { - $ids = $wpdb->get_col($wpdb->prepare("SELECT id FROM {$wpdb->prefix}rapidload_job WHERE rule != %s", 'is_url')); - $ids = implode(',', array_map('intval', $ids)); + $ids = array_map('intval', $wpdb->get_col($wpdb->prepare("SELECT id FROM {$wpdb->prefix}rapidload_job WHERE rule != %s", 'is_url'))); } - if (empty($wherStatus)) { - $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s WHERE job_id IN (%d)", $status, $ids)); - } else { - if($wherStatus === "success"){ - $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s WHERE job_id IN (%d) AND status = %s AND warnings IS NOT NULL", $status, $ids, $wherStatus)); - }else{ - $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s WHERE job_id IN (%d) AND status = %s", $status, $ids, $wherStatus)); + if (empty($ids)) { + return true; + } + + // explicit requeue: clear retry state and any backoff + $set = 'status = %s'; + $args = [ $status ]; + if ($status === 'queued') { + $set .= ', attempts = 0, created_at = %s'; + $args[] = gmdate( 'Y-m-d H:i:s' ); + } + + $where = 'job_id IN (' . implode(',', array_fill(0, count($ids), '%d')) . ')'; + $args = array_merge($args, $ids); + + if ($status === 'queued') { + // rows mid-submit keep their claim; the in-flight result lands normally + $where .= " AND status <> 'submitting'"; + } + + if (!empty($wherStatus)) { + $where .= ' AND status = %s'; + $args[] = $wherStatus; + if ($wherStatus === "success") { + $where .= ' AND warnings IS NOT NULL'; } } + // $set and $where are built only from fixed fragments and %s/%d placeholders + $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}rapidload_job_data SET $set WHERE $where", $args ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared + $error = $wpdb->last_error; if(!empty($error)){ diff --git a/includes/RapidLoad_Job.php b/includes/RapidLoad_Job.php index f8e7fe838..5390142ed 100644 --- a/includes/RapidLoad_Job.php +++ b/includes/RapidLoad_Job.php @@ -63,7 +63,7 @@ public function __construct($args = []) }else{ - $this->created_at = gmdate( "Y-m-d H:m:s", time() ); + $this->created_at = gmdate( "Y-m-d H:i:s", time() ); } } diff --git a/includes/RapidLoad_Job_Data.php b/includes/RapidLoad_Job_Data.php index e45ffd3bb..7eab84d70 100644 --- a/includes/RapidLoad_Job_Data.php +++ b/includes/RapidLoad_Job_Data.php @@ -48,7 +48,7 @@ public function __construct($job, $type) $this->attempts = 0; $this->hits = 0; $this->status = 'queued'; - $this->created_at = gmdate( "Y-m-d H:m:s", time() ); + $this->created_at = gmdate( "Y-m-d H:i:s", time() ); } @@ -120,10 +120,338 @@ public function requeue($attempts = -1){ $this->stats = null; $this->warnings = null; $this->error = null; - $this->created_at = gmdate( "Y-m-d H:m:s", time() ); + $this->created_at = gmdate( "Y-m-d H:i:s", time() ); $this->clearFiles(); } + /** + * Maximum number of API attempts for one job before it is marked as failed. + * Override with the RAPIDLOAD_JOB_MAX_ATTEMPTS constant or the + * 'rapidload/job/max_attempts' filter. + */ + public static function max_attempts(){ + $max = defined('RAPIDLOAD_JOB_MAX_ATTEMPTS') ? (int) RAPIDLOAD_JOB_MAX_ATTEMPTS : 3; + $max = (int) apply_filters('rapidload/job/max_attempts', $max); + return $max > 0 ? $max : 1; + } + + /** + * Exponential backoff (seconds) before a failed attempt may be retried: + * 120s, 240s, 480s ... capped at one hour. + */ + public static function retry_delay($attempts){ + $attempts = max(1, min((int) $attempts, 10)); + $delay = (int) apply_filters('rapidload/job/retry_base_delay', 120) * pow(2, $attempts - 1); + return min($delay, 3600); + } + + /** + * Counts one failed attempt. Requeues the job (not eligible for the queue + * until the backoff has elapsed) while retries are left, otherwise marks + * it as failed with $error. + * + * The cap applies to consecutive failures (see consecutive_retries()), not + * to the lifetime attempts counter: attempts also grows with page-view + * requeues and is never reset by a success, so capping on it would fail a + * long-lived URL on its first transient error. + * + * The backoff is stored as a future created_at; the queue only picks up + * rows with attempts > 0 once created_at <= now (see the *_DB + * get_current_queued_tasks_job_ids methods). + * + * @return bool true when requeued, false when marked as failed + */ + public function retry_or_fail($error){ + + $retries = $this->consecutive_retries() + 1; + + if($retries >= self::max_attempts()){ + $this->forget_retries(); + $this->mark_as_failed($error); + $this->attempts = (int) $this->attempts + 1; + return false; + } + + $this->requeue_delayed($retries); + $this->remember_retries($retries); + + return true; + } + + /** + * Requeue that keeps counting attempts and waits for the exponential + * backoff before the queue may pick the row up again. Use for automatic + * (page view / retry) requeues. Explicit user or content-change requeues + * should call requeue(0) instead, which resets attempts and is eligible + * immediately. + * + * @param int|null $step backoff step; defaults to the new attempts count + */ + public function requeue_delayed($step = null){ + $attempts = (int) $this->attempts + 1; + $this->requeue($attempts); + $this->created_at = gmdate( "Y-m-d H:i:s", time() + self::retry_delay(isset($step) ? $step : $attempts) ); + } + + /** + * Requeue that waits $seconds without counting as a failed retry (e.g. the + * API asked us to come back later with 429 + Retry-After). attempts still + * grows so the queue honours the future created_at. + */ + public function defer($seconds){ + $this->requeue((int) $this->attempts + 1); + $this->created_at = gmdate( "Y-m-d H:i:s", time() + max(60, (int) $seconds) ); + } + + /** + * Handles a failed submit (s/criticalcss, s/unusedcss): + * - 429 with Retry-After: deferred until the API window resets, not counted as a retry + * - timeouts / transport errors / 5xx / other 429: retried with backoff (retry_or_fail) + * - definitive API errors (auth, validation): failed at once + * + * @return bool true when the job stays queued, false when marked as failed + */ + public function handle_submit_error($result, $error, $retry_after = 0){ + + $retry_after = (int) $retry_after; + + if(isset($error['code']) && (int) $error['code'] === 429 && $retry_after > 0){ + $this->defer(min($retry_after, DAY_IN_SECONDS)); + return true; + } + + if(self::is_transient_error($result, $error)){ + return $this->retry_or_fail($error); + } + + $this->mark_as_failed($error); + + return false; + } + + /** + * Number of consecutive failed retries in the current failure episode. + * + * Kept in a transient (no schema change) together with the attempts value + * it was recorded at. Any other change to attempts in between (explicit + * reset, page-view requeue, deferral) or a success ends the episode. + * If the transient is lost (object cache eviction) the count restarts; the + * backoff still grows with attempts, so this stays bounded. + */ + public function consecutive_retries(){ + + if(!isset($this->id)){ + return 0; + } + + $value = get_transient($this->retries_key()); + + if(!is_string($value) || strpos($value, ':') === false){ + return 0; + } + + list($at_attempts, $retries) = explode(':', $value, 2); + + return (int) $at_attempts === (int) $this->attempts ? (int) $retries : 0; + } + + private function remember_retries($retries){ + if(isset($this->id)){ + set_transient($this->retries_key(), (int) $this->attempts . ':' . (int) $retries, DAY_IN_SECONDS); + } + } + + private function forget_retries(){ + if(isset($this->id)){ + delete_transient($this->retries_key()); + } + } + + private function retries_key(){ + return 'rapidload_job_retries_' . (int) $this->id; + } + + /** + * True for failures worth retrying (timeouts, transport errors, 5xx, 429); + * false for definitive API errors (auth, validation) which fail at once. + */ + public static function is_transient_error($result, $error){ + + if(!isset($result) || is_string($result)){ + return true; + } + + $code = isset($error['code']) ? (int) $error['code'] : 0; + + return $code >= 500 || $code === 429; + } + + /** + * Status exposed to the admin UI: the internal 'submitting' claim state is + * shown as 'waiting'. + */ + public static function display_status($status){ + return $status === 'submitting' ? 'waiting' : $status; + } + + /** + * Seconds after which a claimed ('submitting') row is considered abandoned. + * Longer than the 360s API request timeout. + */ + const CLAIM_TIMEOUT = 600; + + /** + * Atomically moves this row out of 'queued' so only one queue run submits it. + * created_at is set to now and doubles as the claim time. + * + * @param bool $any_status claim from any status except 'submitting'; for + * explicit submits (titan) that may run on a row + * that is not 'queued' (e.g. the second strategy + * after the immediate one succeeded or failed) + * @return bool true when this process won the claim + */ + public function claim($any_status = false){ + + if(!isset($this->id)){ + return true; + } + + global $wpdb; + + $now = gmdate( "Y-m-d H:i:s", time() ); + + $status_sql = $any_status ? "status <> 'submitting'" : "status = 'queued'"; + + $affected = $wpdb->query( + $wpdb->prepare( + "UPDATE {$wpdb->prefix}rapidload_job_data SET status = 'submitting', created_at = %s WHERE id = %d AND $status_sql", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- fixed fragment + $now, + $this->id + ) + ); + + if($affected === 1){ + $this->status = 'submitting'; + $this->created_at = $now; + return true; + } + + return false; + } + + /** + * Recovers rows claimed by a run that died (fatal, timeout, killed) before + * the API answered. After CLAIM_TIMEOUT the claim counts as a failed attempt: + * the row returns to 'queued' (with the usual backoff) or, once the attempt + * cap is reached, is marked failed. A row can therefore never stay in + * 'submitting' forever, and a request that crashes PHP cannot loop forever. + */ + public static function release_stale_claims($job_type){ + + global $wpdb; + + $cutoff = gmdate( "Y-m-d H:i:s", time() - self::CLAIM_TIMEOUT ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT job_id FROM {$wpdb->prefix}rapidload_job_data WHERE status = 'submitting' AND job_type = %s AND created_at <= %s ORDER BY created_at ASC LIMIT 50", + $job_type, + $cutoff + ) + ); + + if(empty($rows)){ + return; + } + + foreach ($rows as $row){ + + $job = RapidLoad_Job::find_or_fail($row->job_id); + + if(!$job){ + // orphaned row (job deleted): drop it, like the queue fetchers do, + // so it cannot be selected again on every tick + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->prefix}rapidload_job_data WHERE job_type = %s AND job_id = %d", + $job_type, + $row->job_id + ) + ); + continue; + } + + $job_data = new RapidLoad_Job_Data($job, $job_type); + + // re-check on the loaded object in case another run already recovered it + if($job_data->status !== 'submitting'){ + continue; + } + + $requeued = $job_data->retry_or_fail([ + 'code' => 524, + 'message' => 'Request was interrupted before the API responded' + ]); + $job_data->save(); + + if(!$requeued){ + do_action( 'uucss/cache_cleared', [ + 'url' => $job->url + ]); + } + } + } + + /** + * Minimum seconds between save_post-driven requeues of the same URL + * (default 60 minutes, filter rapidload/job/save_post_min_interval, in minutes). + * Keeps content-change submissions at <= 24/URL/day, within the API's + * per-URL daily limit. A change inside the interval is not dropped: it is + * replayed by a deferred requeue (see schedule_deferred_requeue()). + */ + public static function save_post_min_interval(){ + $minutes = (int) apply_filters('rapidload/job/save_post_min_interval', 60); + return max(0, $minutes) * 60; + } + + const DEFERRED_REQUEUE_HOOK = 'rapidload/job/deferred_requeue'; + + /** + * Schedules one clear_cache replay for this URL once the throttle interval + * (counted from the last requeue / claim) has passed, or shortly after an + * in-flight submit. At most one event per job and type is pending, so a + * save storm still produces a single requeue that includes the latest + * content. Handled by RapidLoad_CriticalCSS / RapidLoad_UnusedCSS. + */ + public function schedule_deferred_requeue(){ + + $args = [ $this->job_type, (int) $this->job_id ]; + + if(wp_next_scheduled(self::DEFERRED_REQUEUE_HOOK, $args)){ + return; + } + + $at = time() + 60; + + if(!empty($this->created_at)){ + $at = max($at, strtotime($this->created_at) + self::save_post_min_interval()); + } + + wp_schedule_single_event($at, self::DEFERRED_REQUEUE_HOOK, $args); + } + + /** + * True when the row was (re)queued or claimed less than $seconds ago. + * created_at is refreshed by requeue() and claim(); a future value + * (backoff pending) also counts as recent. + */ + public function queued_within($seconds){ + if($seconds <= 0 || empty($this->created_at)){ + return false; + } + return (time() - strtotime($this->created_at)) < $seconds; + } + public function clearFiles(){ } @@ -147,10 +475,10 @@ public function mark_as_success($data, $stats, $warnings, $status = 'success', $ $this->stats = isset($stats) ? serialize($stats) : null; $this->warnings = isset($warnings) && count($warnings) > 0 ? $warnings : null; $this->error = null; + $this->forget_retries(); } public function mark_as_successful_hit(){ - $this->attempts = 0; $this->hits++; $this->error = NULL; } diff --git a/includes/RapidLoad_Queue.php b/includes/RapidLoad_Queue.php index 8d7d8e0f0..7506cfd20 100644 --- a/includes/RapidLoad_Queue.php +++ b/includes/RapidLoad_Queue.php @@ -102,8 +102,142 @@ static function get_post_types(){ function uucss_process_queue(){ + self::run_task(); + + } + + const LOCK_OPTION = 'rapidload_queue_lock'; + + /** + * Seconds without a refresh after which a lock is considered abandoned + * (crashed run). A live run refreshes the lock before every job (touch_lock) + * and each job is bounded by the 360s API timeout, so a legitimate run + * never goes this long without a refresh. Rows claimed by a dead run are + * recovered separately by RapidLoad_Job_Data::release_stale_claims. + */ + const LOCK_TTL = 600; + + private static $lock_token = null; + + /** + * Runs the queue task unless another run (WP-Cron or the system cron script) + * holds the lock. Shared by both entry points. + * + * @return bool false when skipped because another run is active + */ + public static function run_task(){ + + if(!self::acquire_lock()){ + return false; + } + + // also released on fatals / Errors / exit(); a no-op once released + register_shutdown_function([__CLASS__, 'release_lock']); + do_action('uucss/queue/task'); + self::release_lock(); + + return true; + } + + /** + * Lock value is "|". INSERT IGNORE on the unique + * option_name succeeds for exactly one caller. An expired lock is taken over + * with a DELETE conditional on the exact stale value, so two callers cannot + * both steal it, and only the holder's token can refresh or release it. + */ + public static function acquire_lock(){ + + global $wpdb; + + $token = uniqid('', true); + + for ($i = 0; $i < 2; $i++) { + + $locked = $wpdb->query( + $wpdb->prepare( + "INSERT IGNORE INTO `$wpdb->options` (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, 'no')", + self::LOCK_OPTION, + $token . '|' . time() + ) + ); + + if($locked){ + self::$lock_token = $token; + return true; + } + + $current = $wpdb->get_var( + $wpdb->prepare( + "SELECT option_value FROM `$wpdb->options` WHERE option_name = %s", + self::LOCK_OPTION + ) + ); + + if($current !== null){ + + $parts = explode('|', $current); + $since = isset($parts[1]) ? (int) $parts[1] : 0; + + if((time() - $since) < self::LOCK_TTL){ + return false; + } + + $wpdb->query( + $wpdb->prepare( + "DELETE FROM `$wpdb->options` WHERE option_name = %s AND option_value = %s", + self::LOCK_OPTION, + $current + ) + ); + } + } + + return false; + } + + /** + * Refresh the lock timestamp; call between long jobs. + */ + public static function touch_lock(){ + + if(self::$lock_token === null){ + return; + } + + global $wpdb; + + $wpdb->query( + $wpdb->prepare( + "UPDATE `$wpdb->options` SET option_value = %s WHERE option_name = %s AND option_value LIKE %s", + self::$lock_token . '|' . time(), + self::LOCK_OPTION, + $wpdb->esc_like(self::$lock_token . '|') . '%' + ) + ); + } + + /** + * Releases only the lock this process holds. + */ + public static function release_lock(){ + + if(self::$lock_token === null){ + return; + } + + global $wpdb; + + $wpdb->query( + $wpdb->prepare( + "DELETE FROM `$wpdb->options` WHERE option_name = %s AND option_value LIKE %s", + self::LOCK_OPTION, + $wpdb->esc_like(self::$lock_token . '|') . '%' + ) + ); + + self::$lock_token = null; } function uucss_process_queue_schedule($schedules){ diff --git a/includes/modules/css/critical-css/RapidLoad_CriticalCSS.php b/includes/modules/css/critical-css/RapidLoad_CriticalCSS.php index e0baec6ae..ff10150ad 100644 --- a/includes/modules/css/critical-css/RapidLoad_CriticalCSS.php +++ b/includes/modules/css/critical-css/RapidLoad_CriticalCSS.php @@ -53,6 +53,8 @@ public function __construct() add_action('rapidload/job/purge', [$this, 'cache_cpcss'], 10, 2); + add_action(RapidLoad_Job_Data::DEFERRED_REQUEUE_HOOK, [$this, 'handle_deferred_requeue'], 10, 2); + add_action('rapidload/job/handle', [$this, 'cache_cpcss'], 10, 2); add_action('rapidload/job/handle', [$this, 'enqueue_cpcss'], 20, 2); @@ -162,6 +164,23 @@ public function refresh( $url, $args = [] ) { $this->cache_cpcss( $job, $args ); } + /** + * Replays a content change that was throttled or arrived while a submit + * was in flight (see RapidLoad_Job_Data::schedule_deferred_requeue()). + */ + public function handle_deferred_requeue($job_type, $job_id){ + + if($job_type !== 'cpcss'){ + return; + } + + $job = RapidLoad_Job::find_or_fail($job_id); + + if($job){ + $this->clear_cache($job, ['throttle' => true]); + } + } + public function clear_on_actions($post_id) { if(!$post_id){ @@ -178,7 +197,8 @@ public function clear_on_actions($post_id) if(isset($job->id)){ - $this->clear_cache($job); + // content-change path: throttled, see clear_cache() + $this->clear_cache($job, ['throttle' => true]); } } @@ -217,12 +237,36 @@ function clear_cache($job = null, $args = []){ if(isset($job_data->id) && (!isset($job_data->job->rule_id) && $job_data->job->rule === "is_url" || $job_data->job->rule !== "is_url")){ + if($job_data->status === 'submitting'){ + // A submit is in flight; requeueing now would let a second runner + // claim the row and send a duplicate. Replay the change once the + // submit is over instead of dropping it. + $job_data->schedule_deferred_requeue(); + self::log([ + 'log' => 'skip requeue-> cpcss clear cache while submitting', + 'url' => $job_data->job->url, + ]); + return; + } + + if(!empty($args['throttle']) && $job_data->queued_within(RapidLoad_Job_Data::save_post_min_interval())){ + // save_post / importer storms: at most one requeue per interval per URL. + // Files are kept (no clear_files) and the current job stays as is; + // a single deferred requeue picks up the latest content afterwards. + $job_data->schedule_deferred_requeue(); + self::log([ + 'log' => 'skip requeue-> cpcss clear cache throttled', + 'url' => $job_data->job->url, + ]); + return; + } + $this->clear_files($job_data); self::log([ 'log' => 'requeue-> cpcss clear cache by id manually', 'url' => $job_data->job->url, ]); - $job_data->requeue(); + $job_data->requeue(0); $job_data->save(); } @@ -334,7 +378,7 @@ function update_link($link){ 'id' => $job_data->id, 'job_id' => $job_data->job->id, 'queue_job_id' => $job_data->queue_job_id, - 'status' => $job_data->status, + 'status' => RapidLoad_Job_Data::display_status($job_data->status), 'job' => (array) $job_data->job, ]; $link['meta'] = [ @@ -344,7 +388,7 @@ function update_link($link){ ]; $link['attempts'] = $job_data->attempts; if($job->rule !== 'is_url'){ - $link['rule_status'] = $job_data->status; + $link['rule_status'] = RapidLoad_Job_Data::display_status($job_data->status); $link['rule_hits'] = $job_data->hits; $link['applied_links'] = count($job->get_urls()); } @@ -382,12 +426,16 @@ function cache_cpcss($job, $args = []){ return false; } - if(!in_array($this->job_data->status, ['success', 'waiting', 'processing','queued']) || isset( $args['immediate']) || isset( $args['requeue'])){ + if($this->job_data->status !== 'submitting' && (!in_array($this->job_data->status, ['success', 'waiting', 'processing','queued']) || isset( $args['immediate']) || isset( $args['requeue']))){ self::log([ 'log' => 'requeue-> cpcss requeue manually', 'url' => $this->job_data->job->url, ]); - $this->job_data->requeue(isset( $args['immediate']) || isset( $args['requeue']) ? 1 : -1); + if(isset( $args['immediate']) || isset( $args['requeue'])){ + $this->job_data->requeue(0); + }else{ + $this->job_data->requeue_delayed(); + } $this->job_data->save(); } @@ -478,7 +526,7 @@ public function init_async_store($job_data, $args) public function cleanCacheFiles(){ - $data = RapidLoad_CriticalCSS_DB::get_success_data(); + $data = RapidLoad_CriticalCSS_DB::get_success_data(0); $used_files = []; diff --git a/includes/modules/css/critical-css/RapidLoad_CriticalCSS_DB.php b/includes/modules/css/critical-css/RapidLoad_CriticalCSS_DB.php index 3fd669c1a..3f0ea0633 100644 --- a/includes/modules/css/critical-css/RapidLoad_CriticalCSS_DB.php +++ b/includes/modules/css/critical-css/RapidLoad_CriticalCSS_DB.php @@ -9,7 +9,7 @@ public static function clear_data($soft = false){ global $wpdb; if($soft){ - $wpdb->query( $wpdb->prepare("UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, queue_job_id = NULL, data = NULL, stats = NULL, warnings = NULL, error = NULL, hits = 0 WHERE job_type = %s", 'queued', 'cpcss')); + $wpdb->query( $wpdb->prepare("UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, queue_job_id = NULL, data = NULL, stats = NULL, warnings = NULL, error = NULL, hits = 0, attempts = 0, created_at = %s WHERE job_type = %s AND status <> 'submitting'", 'queued', gmdate( 'Y-m-d H:i:s' ), 'cpcss')); }else{ $wpdb->query( $wpdb->prepare("DELETE FROM {$wpdb->prefix}rapidload_job_data WHERE job_type = %s", 'cpcss')); } @@ -28,22 +28,25 @@ public static function requeue_where_status($status = ''){ if(empty($status)){ $wpdb->query( $wpdb->prepare( - "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, queue_job_id = NULL, data = NULL, stats = NULL, warnings = NULL, error = NULL, hits = 0 WHERE job_type='cpcss'", - 'queued' + "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, queue_job_id = NULL, data = NULL, stats = NULL, warnings = NULL, error = NULL, hits = 0, attempts = 0, created_at = %s WHERE job_type='cpcss' AND status <> 'submitting'", + 'queued', + gmdate( 'Y-m-d H:i:s' ) ) ); }else if($status === 'success'){ $wpdb->query( $wpdb->prepare( - "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, queue_job_id = NULL, data = NULL, stats = NULL, warnings = NULL, error = NULL, hits = 0 WHERE job_type='cpcss' AND status = 'success' AND warnings IS NOT NULL", - 'queued' + "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, queue_job_id = NULL, data = NULL, stats = NULL, warnings = NULL, error = NULL, hits = 0, attempts = 0, created_at = %s WHERE job_type='cpcss' AND status = 'success' AND warnings IS NOT NULL", + 'queued', + gmdate( 'Y-m-d H:i:s' ) ) ); }else{ $wpdb->query( $wpdb->prepare( - "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, queue_job_id = NULL, data = NULL, stats = NULL, warnings = NULL, error = NULL, hits = 0 WHERE job_type='cpcss' AND status = %s", + "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, queue_job_id = NULL, data = NULL, stats = NULL, warnings = NULL, error = NULL, hits = 0, attempts = 0, created_at = %s WHERE job_type='cpcss' AND status = %s", 'queued', + gmdate( 'Y-m-d H:i:s' ), $status ) ); @@ -100,8 +103,9 @@ public static function get_current_queued_tasks_job_ids($limit = 1){ $job_ids = $wpdb->get_results( $wpdb->prepare( - "SELECT job_id FROM {$wpdb->prefix}rapidload_job_data WHERE status = 'queued' AND job_type = %s ORDER BY id DESC LIMIT %d", + "SELECT job_id FROM {$wpdb->prefix}rapidload_job_data WHERE status = 'queued' AND job_type = %s AND (attempts IS NULL OR attempts < 1 OR created_at <= %s) ORDER BY id DESC LIMIT %d", 'cpcss', + gmdate( 'Y-m-d H:i:s', time() ), $limit ), OBJECT @@ -140,12 +144,15 @@ public static function get_current_processing_tasks_job_ids($limit = 1){ public static function get_success_data($limit = 1) { global $wpdb; + // $limit = 0 (or negative) returns every successful row. + $limit = (int) $limit; + $limit_sql = $limit > 0 ? $wpdb->prepare( ' LIMIT %d', $limit ) : ''; + $data = $wpdb->get_results( $wpdb->prepare( - "SELECT data FROM {$wpdb->prefix}rapidload_job_data WHERE status = 'success' AND job_type = %s ORDER BY id DESC LIMIT %d", - 'cpcss', - $limit - ), + "SELECT data FROM {$wpdb->prefix}rapidload_job_data WHERE status = 'success' AND job_type = %s ORDER BY id DESC", + 'cpcss' + ) . $limit_sql, OBJECT ); diff --git a/includes/modules/css/critical-css/RapidLoad_CriticalCSS_Enqueue.php b/includes/modules/css/critical-css/RapidLoad_CriticalCSS_Enqueue.php index 2fbb07391..c5479c019 100644 --- a/includes/modules/css/critical-css/RapidLoad_CriticalCSS_Enqueue.php +++ b/includes/modules/css/critical-css/RapidLoad_CriticalCSS_Enqueue.php @@ -90,7 +90,7 @@ function update_content($state){ 'log' => 'requeue-> critical css file not found and attempts less than two or last generated days more than one', 'url' => $this->job_data->job->url, ]); - $this->job_data->requeue(); + $this->job_data->requeue_delayed(); $this->job_data->save(); //$this->inject->successfully_injected = false; return [ diff --git a/includes/modules/css/critical-css/RapidLoad_CriticalCSS_Queue.php b/includes/modules/css/critical-css/RapidLoad_CriticalCSS_Queue.php index 7187532fd..7a76e673f 100644 --- a/includes/modules/css/critical-css/RapidLoad_CriticalCSS_Queue.php +++ b/includes/modules/css/critical-css/RapidLoad_CriticalCSS_Queue.php @@ -17,6 +17,8 @@ public function __construct() function fetch_job_id(){ + RapidLoad_Job_Data::release_stale_claims('cpcss'); + $current_waiting = RapidLoad_CriticalCSS_DB::get_current_waiting_tasks_count(); if( (RapidLoad_Queue::$job_count - $current_waiting) <= 0 ){ @@ -31,6 +33,8 @@ function fetch_job_id(){ foreach ($links as $link){ + RapidLoad_Queue::touch_lock(); + $job = RapidLoad_Job::find_or_fail($link->job_id); if($job){ @@ -60,6 +64,8 @@ function fetch_result(){ foreach ($links as $link){ + RapidLoad_Queue::touch_lock(); + $job = RapidLoad_Job::find_or_fail($link->job_id); if($job){ diff --git a/includes/modules/css/critical-css/RapidLoad_CriticalCSS_Store.php b/includes/modules/css/critical-css/RapidLoad_CriticalCSS_Store.php index c0ae698d1..37fcaaf7e 100644 --- a/includes/modules/css/critical-css/RapidLoad_CriticalCSS_Store.php +++ b/includes/modules/css/critical-css/RapidLoad_CriticalCSS_Store.php @@ -35,7 +35,7 @@ function purge_css(){ $discontinue = false; - if(!isset($this->args['titan']) && ($this->job_data->status === 'waiting' || $this->job_data->status === 'processing' || $this->job_data->status === 'success')){ + if(!isset($this->args['titan']) && ($this->job_data->status === 'waiting' || $this->job_data->status === 'processing' || $this->job_data->status === 'submitting' || $this->job_data->status === 'success')){ return; } @@ -103,6 +103,13 @@ function purge_css(){ $cpcss_config = apply_filters('cpcss/purge/config', ( isset( $this->args['options'] ) ) ? $this->args['options'] : []); + // titan submits explicitly (e.g. the second strategy right after the + // immediate one), so the row need not be 'queued' + if(!$this->job_data->claim(isset($this->args['titan']))){ + // another queue run already took this job + return; + } + $result = $uucss_api->post( 's/criticalcss', array_merge( $cpcss_config, [ @@ -120,11 +127,15 @@ function purge_css(){ if($uucss_api->is_error($result)){ - $this->job_data->mark_as_failed($uucss_api->extract_error( $result )); + $error = $uucss_api->extract_error( $result ); + + // 429 + Retry-After waits for the API window, timeouts / transport + // errors / 5xx are retried with backoff, definitive API errors fail at once + $requeued = $this->job_data->handle_submit_error($result, $error, $uucss_api->retry_after); $this->job_data->save(); $this->log( [ - 'log' => 'fetched data stored status failed', + 'log' => $requeued ? 'submit failed, requeued (attempt ' . $this->job_data->attempts . ')' : 'fetched data stored status failed', 'url' => $this->job_data->job->url, 'type' => 'uucss-cron' ] ); @@ -138,7 +149,7 @@ function purge_css(){ $this->job_data->status = 'waiting'; $this->job_data->save(); - }else if($result->completed){ + }else if(is_object($result) && !empty($result->completed)){ $this->result = $result; $this->purged_css = $result->data; @@ -147,6 +158,32 @@ function purge_css(){ $this->cache_file($this->purged_css, $this->purged_mobile_css); $this->cpcss_cached($this->job_data->job->url); + }else{ + + // Neither a queued job id nor a completed result: count the attempt + // instead of silently leaving the row 'queued' to be resent every tick. + $error = [ + 'code' => 500, + 'message' => is_object($result) && isset($result->message) && is_string($result->message) + ? $result->message + : 'Unexpected response from RapidLoad API' + ]; + + $requeued = $this->job_data->retry_or_fail($error); + $this->job_data->save(); + + $this->log( [ + 'log' => ($requeued ? 'requeue-> ' : 'giving up-> ') . 'unexpected response from api (attempt ' . $this->job_data->attempts . ')', + 'url' => $this->job_data->job->url, + 'type' => 'uucss-cron' + ] ); + + if(!$requeued){ + do_action( 'uucss/cache_cleared', [ + 'url' => $this->job_data->job->url + ]); + } + } } @@ -235,13 +272,20 @@ function update_css(){ if(isset($error['message']) && ($error['message'] === 'Job processing failed in queue' || $error['message'] === 'Error')){ + $requeued = $this->job_data->retry_or_fail($error); + self::log([ - 'log' => 'requeue-> cpcss job processing failed in queue', + 'log' => ($requeued ? 'requeue-> ' : 'giving up-> ') . 'cpcss job processing failed in queue (attempt ' . $this->job_data->attempts . ')', 'url' => $this->job_data->job->url, ]); - $this->job_data->requeue(); $this->job_data->save(); + if(!$requeued){ + do_action( 'uucss/cache_cleared', [ + 'url' => $this->job_data->job->url + ]); + } + return; } diff --git a/includes/modules/css/unused-css/RapidLoad_UnusedCSS.php b/includes/modules/css/unused-css/RapidLoad_UnusedCSS.php index 3ebd30f7c..bc8214cd1 100644 --- a/includes/modules/css/unused-css/RapidLoad_UnusedCSS.php +++ b/includes/modules/css/unused-css/RapidLoad_UnusedCSS.php @@ -42,6 +42,8 @@ public function __construct() add_action('rapidload/job/purge', [$this, 'cache_uucss'], 10, 2); + add_action(RapidLoad_Job_Data::DEFERRED_REQUEUE_HOOK, [$this, 'handle_deferred_requeue'], 10, 2); + add_action('uucss_async_queue', [$this, 'init_async_store'], 10, 2); if(apply_filters('uucss/enable/notfound_fallback', true)){ @@ -205,13 +207,13 @@ function update_link($link){ if(isset($job_data->id)){ if($job->rule !== 'is_url'){ - //$link['rule_status'] = $job_data->status; + //$link['rule_status'] = RapidLoad_Job_Data::display_status($job_data->status); // $link['rule_hits'] = $job_data->hits; //$link['applied_links'] = count($job->get_urls()); } if(!isset($link['status'])){ - $link['status'] = $job_data->status; + $link['status'] = RapidLoad_Job_Data::display_status($job_data->status); } $link['success_count'] = $job_data->hits; $link['files'] = $job_data->get_files(); @@ -219,7 +221,7 @@ function update_link($link){ $link['meta']['warnings'] = $job_data->get_warnings(); $link['meta']['stats'] = isset($job_data->stats) ? unserialize($job_data->stats) : null; $link['meta']['error'] = isset($job_data->error) ? unserialize($job_data->error) : null; - $link['meta']['status'] = isset( $job_data->status ) ? $job_data->status : null; + $link['meta']['status'] = isset( $job_data->status ) ? RapidLoad_Job_Data::display_status($job_data->status) : null; $link['time'] = isset( $job_data->created_at ) ? strtotime( $job_data->created_at ) : null; $link['attempts'] = isset( $job_data->attempts ) ? $job_data->attempts : null; $link['rule'] = $job_data->job->rule; @@ -339,12 +341,16 @@ function cache_uucss($job, $args = []){ return false; } - if(!in_array($this->job_data->status, ['success', 'waiting', 'processing','queued']) || isset( $args['immediate']) || isset( $args['requeue'])){ + if($this->job_data->status !== 'submitting' && (!in_array($this->job_data->status, ['success', 'waiting', 'processing','queued']) || isset( $args['immediate']) || isset( $args['requeue']))){ self::log([ 'log' => 'requeue-> uucss requeue manually', 'url' => $this->job_data->job->url, ]); - $this->job_data->requeue(isset( $args['immediate']) || isset( $args['requeue']) ? 1 : -1); + if(isset( $args['immediate']) || isset( $args['requeue'])){ + $this->job_data->requeue(0); + }else{ + $this->job_data->requeue_delayed(); + } if(isset( $args['immediate'])){ $this->job_data->status = 'processing'; } @@ -451,6 +457,23 @@ public function cleanCacheFiles(){ } + /** + * Replays a content change that was throttled or arrived while a submit + * was in flight (see RapidLoad_Job_Data::schedule_deferred_requeue()). + */ + public function handle_deferred_requeue($job_type, $job_id){ + + if($job_type !== 'uucss'){ + return; + } + + $job = RapidLoad_Job::find_or_fail($job_id); + + if($job){ + $this->clear_cache($job, ['throttle' => true]); + } + } + public function clear_on_actions($post_id) { if(!$post_id){ @@ -467,7 +490,8 @@ public function clear_on_actions($post_id) if(isset($job->id)){ - $this->clear_cache($job); + // content-change path: throttled, see clear_cache() + $this->clear_cache($job, ['throttle' => true]); } } @@ -481,12 +505,36 @@ function clear_cache($job = null, $args = []){ if(isset($job_data->id) && (!isset($job_data->job->rule_id) && $job_data->job->rule === "is_url" || $job_data->job->rule !== "is_url")){ + if($job_data->status === 'submitting'){ + // A submit is in flight; requeueing now would let a second runner + // claim the row and send a duplicate. Replay the change once the + // submit is over instead of dropping it. + $job_data->schedule_deferred_requeue(); + self::log([ + 'log' => 'skip requeue-> uucss clear cache while submitting', + 'url' => $job_data->job->url, + ]); + return; + } + + if(!empty($args['throttle']) && $job_data->queued_within(RapidLoad_Job_Data::save_post_min_interval())){ + // save_post / importer storms: at most one requeue per interval per URL. + // Files are kept (no clear_files) and the current job stays as is; + // a single deferred requeue picks up the latest content afterwards. + $job_data->schedule_deferred_requeue(); + self::log([ + 'log' => 'skip requeue-> uucss clear cache throttled', + 'url' => $job_data->job->url, + ]); + return; + } + $this->clear_files($job_data); self::log([ 'log' => 'requeue-> clear cache by job id manually', 'url' => $job_data->job->url, ]); - $job_data->requeue(); + $job_data->requeue(0); $job_data->save(); } diff --git a/includes/modules/css/unused-css/RapidLoad_UnusedCSS_DB.php b/includes/modules/css/unused-css/RapidLoad_UnusedCSS_DB.php index fa7a0243e..11a04b8bc 100644 --- a/includes/modules/css/unused-css/RapidLoad_UnusedCSS_DB.php +++ b/includes/modules/css/unused-css/RapidLoad_UnusedCSS_DB.php @@ -11,22 +11,25 @@ public static function requeue_where_status($status = ''){ if(empty($status)){ $wpdb->query( $wpdb->prepare( - "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, queue_job_id = NULL, data = NULL, stats = NULL, warnings = NULL, error = NULL, hits = 0 WHERE job_type='uucss'", - 'queued' + "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, queue_job_id = NULL, data = NULL, stats = NULL, warnings = NULL, error = NULL, hits = 0, attempts = 0, created_at = %s WHERE job_type='uucss' AND status <> 'submitting'", + 'queued', + gmdate( 'Y-m-d H:i:s' ) ) ); }else if($status === 'success'){ $wpdb->query( $wpdb->prepare( - "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, queue_job_id = NULL, data = NULL, stats = NULL, warnings = NULL, error = NULL, hits = 0 WHERE job_type='uucss' AND status = 'success' AND warnings IS NOT NULL", - 'queued' + "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, queue_job_id = NULL, data = NULL, stats = NULL, warnings = NULL, error = NULL, hits = 0, attempts = 0, created_at = %s WHERE job_type='uucss' AND status = 'success' AND warnings IS NOT NULL", + 'queued', + gmdate( 'Y-m-d H:i:s' ) ) ); }else{ $wpdb->query( $wpdb->prepare( - "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, queue_job_id = NULL, data = NULL, stats = NULL, warnings = NULL, error = NULL, hits = 0 WHERE job_type='uucss' AND status = %s", + "UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, queue_job_id = NULL, data = NULL, stats = NULL, warnings = NULL, error = NULL, hits = 0, attempts = 0, created_at = %s WHERE job_type='uucss' AND status = %s", 'queued', + gmdate( 'Y-m-d H:i:s' ), $status ) ); @@ -44,7 +47,7 @@ public static function clear_data($soft = false){ global $wpdb; if($soft){ - $wpdb->query( $wpdb->prepare("UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, queue_job_id = NULL, data = NULL, stats = NULL, warnings = NULL, error = NULL, hits = 0 WHERE job_type = %s", 'queued', 'uucss')); + $wpdb->query( $wpdb->prepare("UPDATE {$wpdb->prefix}rapidload_job_data SET status = %s, queue_job_id = NULL, data = NULL, stats = NULL, warnings = NULL, error = NULL, hits = 0, attempts = 0, created_at = %s WHERE job_type = %s AND status <> 'submitting'", 'queued', gmdate( 'Y-m-d H:i:s' ), 'uucss')); }else{ $wpdb->query( $wpdb->prepare("DELETE FROM {$wpdb->prefix}rapidload_job_data WHERE job_type = %s", 'uucss')); } @@ -136,7 +139,7 @@ public static function get_current_waiting_tasks_count(){ $count = $wpdb->get_var( $wpdb->prepare( - "SELECT COUNT(id) FROM {$wpdb->prefix}rapidload_job_data WHERE status = 'processing' OR status = 'waiting' AND job_type = %s", + "SELECT COUNT(id) FROM {$wpdb->prefix}rapidload_job_data WHERE (status = 'processing' OR status = 'waiting') AND job_type = %s", 'uucss' ) ); @@ -149,8 +152,9 @@ public static function get_current_queued_tasks_job_ids($limit = 1){ $job_ids = $wpdb->get_results( $wpdb->prepare( - "SELECT job_id FROM {$wpdb->prefix}rapidload_job_data WHERE status = 'queued' AND job_type = %s ORDER BY id DESC LIMIT %d", + "SELECT job_id FROM {$wpdb->prefix}rapidload_job_data WHERE status = 'queued' AND job_type = %s AND (attempts IS NULL OR attempts < 1 OR created_at <= %s) ORDER BY id DESC LIMIT %d", 'uucss', + gmdate( 'Y-m-d H:i:s', time() ), $limit ), OBJECT @@ -164,7 +168,7 @@ public static function get_current_processing_tasks_job_ids($limit = 1){ $job_ids = $wpdb->get_results( $wpdb->prepare( - "SELECT job_id FROM {$wpdb->prefix}rapidload_job_data WHERE status = 'processing' OR status = 'waiting' AND job_type = %s ORDER BY id DESC LIMIT %d", + "SELECT job_id FROM {$wpdb->prefix}rapidload_job_data WHERE (status = 'processing' OR status = 'waiting') AND job_type = %s ORDER BY id DESC LIMIT %d", 'uucss', $limit ), diff --git a/includes/modules/css/unused-css/RapidLoad_UnusedCSS_Enqueue.php b/includes/modules/css/unused-css/RapidLoad_UnusedCSS_Enqueue.php index afc29503b..577e88496 100644 --- a/includes/modules/css/unused-css/RapidLoad_UnusedCSS_Enqueue.php +++ b/includes/modules/css/unused-css/RapidLoad_UnusedCSS_Enqueue.php @@ -375,7 +375,7 @@ public function enqueue_completed(){ 'log' => 'requeue-> uucss requeue due to warnings', 'url' => $this->job_data->job->url, ]); - $this->job_data->requeue(); + $this->job_data->requeue_delayed(); }else{ diff --git a/includes/modules/css/unused-css/RapidLoad_UnusedCSS_Queue.php b/includes/modules/css/unused-css/RapidLoad_UnusedCSS_Queue.php index 53f48dc11..44b021772 100644 --- a/includes/modules/css/unused-css/RapidLoad_UnusedCSS_Queue.php +++ b/includes/modules/css/unused-css/RapidLoad_UnusedCSS_Queue.php @@ -18,6 +18,8 @@ public function __construct() function fetch_job_id(){ + RapidLoad_Job_Data::release_stale_claims('uucss'); + $current_waiting = RapidLoad_UnusedCSS_DB::get_current_waiting_tasks_count(); if( (RapidLoad_Queue::$job_count - $current_waiting) <= 0 ){ @@ -32,6 +34,8 @@ function fetch_job_id(){ foreach ($links as $link){ + RapidLoad_Queue::touch_lock(); + $job = RapidLoad_Job::find_or_fail($link->job_id); if($job){ @@ -61,6 +65,8 @@ function fetch_result(){ foreach ($links as $link){ + RapidLoad_Queue::touch_lock(); + $job = RapidLoad_Job::find_or_fail($link->job_id); if($job){ diff --git a/includes/modules/css/unused-css/RapidLoad_UnusedCSS_Store.php b/includes/modules/css/unused-css/RapidLoad_UnusedCSS_Store.php index a95e8a1b8..bc66431fc 100644 --- a/includes/modules/css/unused-css/RapidLoad_UnusedCSS_Store.php +++ b/includes/modules/css/unused-css/RapidLoad_UnusedCSS_Store.php @@ -33,7 +33,7 @@ public function purge_css() { if(isset($this->args['immediate']) || isset($this->args['titan'])){ - if(isset($this->args['titan']) && ($this->job_data->status === 'waiting' || $this->job_data->status === 'processing' || $this->job_data->status === 'success')){ + if(isset($this->args['titan']) && ($this->job_data->status === 'waiting' || $this->job_data->status === 'processing' || $this->job_data->status === 'submitting' || $this->job_data->status === 'success')){ return; } @@ -89,6 +89,11 @@ public function purge_css() { $uucss_config = apply_filters('uucss/purge/config', $api_options); + if(!$this->job_data->claim()){ + // another queue run already took this job + return; + } + $result = $uucss_api->post( 's/unusedcss', array_merge( $uucss_config, [ @@ -102,11 +107,15 @@ public function purge_css() { if($uucss_api->is_error($result)){ - $this->job_data->mark_as_failed($uucss_api->extract_error( $result )); + $error = $uucss_api->extract_error( $result ); + + // 429 + Retry-After waits for the API window, timeouts / transport + // errors / 5xx are retried with backoff, definitive API errors fail at once + $requeued = $this->job_data->handle_submit_error($result, $error, $uucss_api->retry_after); $this->job_data->save(); $this->log( [ - 'log' => 'fetched data stored status failed', + 'log' => $requeued ? 'submit failed, requeued (attempt ' . $this->job_data->attempts . ')' : 'fetched data stored status failed', 'url' => $this->job_data->job->url, 'type' => 'uucss-cron' ] ); @@ -120,7 +129,7 @@ public function purge_css() { $this->job_data->status = 'waiting'; $this->job_data->save(); - }else if($result->completed){ + }else if(is_object($result) && !empty($result->completed)){ $this->result = $result; $this->purged_css = $result->data; @@ -128,6 +137,32 @@ public function purge_css() { $this->cache_files($this->purged_css); $this->uucss_cached($this->job_data->job->url); + }else{ + + // Neither a queued job id nor a completed result: count the attempt + // instead of silently leaving the row 'queued' to be resent every tick. + $error = [ + 'code' => 500, + 'message' => is_object($result) && isset($result->message) && is_string($result->message) + ? $result->message + : 'Unexpected response from RapidLoad API' + ]; + + $requeued = $this->job_data->retry_or_fail($error); + $this->job_data->save(); + + $this->log( [ + 'log' => ($requeued ? 'requeue-> ' : 'giving up-> ') . 'unexpected response from api (attempt ' . $this->job_data->attempts . ')', + 'url' => $this->job_data->job->url, + 'type' => 'uucss-cron' + ] ); + + if(!$requeued){ + do_action( 'uucss/cache_cleared', [ + 'url' => $this->job_data->job->url + ]); + } + } } @@ -250,13 +285,20 @@ function update_css(){ if(isset($error['message']) && ($error['message'] === 'Job processing failed in queue' || $error['message'] === 'Error')){ + $requeued = $this->job_data->retry_or_fail($error); + self::log([ - 'log' => 'requeue-> uucss job processing failed in queue', + 'log' => ($requeued ? 'requeue-> ' : 'giving up-> ') . 'uucss job processing failed in queue (attempt ' . $this->job_data->attempts . ')', 'url' => $this->job_data->job->url, ]); - $this->job_data->requeue(); $this->job_data->save(); + if(!$requeued){ + do_action( 'uucss/cache_cleared', [ + 'url' => $this->job_data->job->url + ]); + } + return; } diff --git a/includes/modules/optimizer/RapidLoad_Job_Optimization.php b/includes/modules/optimizer/RapidLoad_Job_Optimization.php index 1ae696dc1..a2ce78620 100644 --- a/includes/modules/optimizer/RapidLoad_Job_Optimization.php +++ b/includes/modules/optimizer/RapidLoad_Job_Optimization.php @@ -28,7 +28,7 @@ public function __construct($job, $strategy, $id = null) $this->data = $exist->data; $this->created_at = $exist->created_at; }else{ - $this->created_at = gmdate( "Y-m-d H:m:s", time() ); + $this->created_at = gmdate( "Y-m-d H:i:s", time() ); } } diff --git a/includes/modules/optimizer/RapidLoad_Optimizer.php b/includes/modules/optimizer/RapidLoad_Optimizer.php index 176ee3147..8b25bf9dd 100644 --- a/includes/modules/optimizer/RapidLoad_Optimizer.php +++ b/includes/modules/optimizer/RapidLoad_Optimizer.php @@ -453,7 +453,7 @@ public function rapidload_css_job_status(){ case 'uucss' :{ $job_data_uucss = new RapidLoad_Job_Data($job,'uucss'); $response[$type] = [ - 'status' => isset($job_data_uucss->id) ? $job_data_uucss->status : 'queued', + 'status' => isset($job_data_uucss->id) ? RapidLoad_Job_Data::display_status($job_data_uucss->status) : 'queued', 'error' => isset($job_data_uucss->id) && isset($job_data_uucss->error) ? unserialize($job_data_uucss->error) : null, 'meta' => [ 'warnings' => isset($job_data_uucss->id) && isset($job_data_uucss->warnings) ? unserialize($job_data_uucss->warnings) : null, @@ -466,7 +466,7 @@ public function rapidload_css_job_status(){ $job_data_cpcss = new RapidLoad_Job_Data($job,'cpcss'); $cpcss_data = $job_data_cpcss->get_cpcss_data(); $response[$type] = [ - 'status' => isset($job_data_cpcss->id) ? $job_data_cpcss->status : 'queued', + 'status' => isset($job_data_cpcss->id) ? RapidLoad_Job_Data::display_status($job_data_cpcss->status) : 'queued', 'error' => isset($job_data_cpcss->id) && isset($job_data_cpcss->error) ? unserialize($job_data_cpcss->error) : null, 'meta' => [ 'warnings' => isset($job_data_cpcss->id) && isset($job_data_cpcss->warnings) ? unserialize($job_data_cpcss->warnings) : null, @@ -1501,7 +1501,7 @@ function get_settings_with_inputs($url , $keys, $settings, $options = array()) { }else if($input['key'] === "uucss_enable_uucss"){ $data = new RapidLoad_Job_Data(self::$job, 'uucss'); $settings['status'] = isset($options[$input['key']]) && $options[$input['key']] === "1" ? [ - 'status' => isset($data->id) ? $data->status : 'queued', + 'status' => isset($data->id) ? RapidLoad_Job_Data::display_status($data->status) : 'queued', 'error' => isset($data->id) ? $data->get_error() : null, 'meta' => [ 'stats' => isset($data->id) ? $data->get_stats() : null, @@ -1515,7 +1515,7 @@ function get_settings_with_inputs($url , $keys, $settings, $options = array()) { $data = new RapidLoad_Job_Data(self::$job, 'cpcss'); $cpcss_data = $data->get_cpcss_data(); $settings['status'] = isset($options[$input['key']]) && $options[$input['key']] === "1" ? [ - 'status' => isset($data->id) ? $data->status : 'queued', + 'status' => isset($data->id) ? RapidLoad_Job_Data::display_status($data->status) : 'queued', 'error' => isset($data->id) ? $data->get_error() : null, 'meta' => [ 'desktop' => isset($data->id) && isset($cpcss_data['desktop']) && !empty($cpcss_data['desktop']) ? $cpcss_data['desktop'] : null, diff --git a/rapidload_cron.php b/rapidload_cron.php index 4f4b7315c..b8e0e788b 100644 --- a/rapidload_cron.php +++ b/rapidload_cron.php @@ -70,7 +70,13 @@ function rapidload_cron_log($message) { rapidload_cron_log('WordPress loaded from: ' . ABSPATH); // Trigger the optimization process - do_action('uucss/queue/task'); + if (class_exists('RapidLoad_Queue')) { + if (!RapidLoad_Queue::run_task()) { + rapidload_cron_log('Another RapidLoad queue run is active, skipping'); + } + } else { + do_action('uucss/queue/task'); + } // Log completion rapidload_cron_log('RapidLoad optimization process completed successfully');