Repository navigation
Fix job resubmission loop: retry cap, backoff, atomic claim - #234
Merged
Merged
Conversation
gmdate('Y-m-d H:m:s') wrote the month number into the minutes slot, so
created_at was off by up to an hour. Use H:i:s.
mark_as_successful_hit() zeroed attempts on each view, which defeats any retry cap. Attempts are now reset only when the API actually returns a successful result (mark_as_success), which ends the retry cycle.
get_success_data() defaulted to LIMIT 1, so cleanCacheFiles() treated only one URL's files as in use and deleted every other cached CPCSS file, which forced those URLs to be requeued on their next view. A limit of 0 now returns all rows; cleanCacheFiles passes 0. Default (1) is unchanged for other callers. The UnusedCSS cleanCacheFiles() is an empty stub, so it is not affected.
update_css() requeued forever on 'Job processing failed in queue'/'Error' with no cap and no delay, so a failing URL was resubmitted every queue tick. Add RapidLoad_Job_Data::retry_or_fail(): counts the attempt, requeues with exponential backoff (120s, 240s, ... max 1h, stored as a future created_at, no schema change) while attempts < max (default 3, filter rapidload/job/max_attempts or RAPIDLOAD_JOB_MAX_ATTEMPTS), then marks the job failed. The queued-job SELECTs skip retry rows (attempts >= 1) until created_at <= now. Rows with attempts = 0 are unaffected, and user/page-view requeues set created_at = now so they stay immediately eligible.
A response with neither id nor completed (e.g. {"message":"rate limited"})
left the row 'queued' and it was resent on every queue tick. Now it counts
an attempt via retry_or_fail() (backoff, then failed after the cap) for both
CPCSS and UUCSS. Also guards against non-object results.
The row only left 'queued' after the (up to 360s) API POST returned, so overlapping WP-Cron / rapidload_cron.php runs resent the same row. - RapidLoad_Job_Data::claim(): UPDATE ... SET status='submitting' WHERE id=%d AND status='queued'; the submitter skips the job if 0 rows changed. - RapidLoad_Job_Data::release_stale_claims(): a row stuck in 'submitting' for > 600s (run crashed/killed) counts as a failed attempt and goes back to 'queued' with backoff, or to 'failed' at the attempt cap. Called at the start of fetch_job_id for cpcss and uucss. - RapidLoad_Queue::run_task(): short-lived lock (INSERT IGNORE on an options row, expires after 600s so a crashed run cannot block forever) used by both the WP-Cron hook and rapidload_cron.php.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
…tdown - Lock value is "<token>|<time>"; release and refresh only touch the holder's own lock, so a finishing run cannot delete a newer run's lock. - touch_lock() is called before every job so a legitimately long run (job_count x 360s) is never taken over; a crashed run's lock still expires after 600s without refresh. - Release also runs from a shutdown function, covering PHP Errors, fatals and exit() on all PHP versions (no Throwable/finally needed).
…errors - cache_cpcss/cache_uucss no longer requeue a row that is currently 'submitting' (a page view or manual action mid-POST could reset it to queued and allow a second runner to claim and duplicate the POST); the immediate/titan guards in both Stores also skip 'submitting'. - Errors right after the claim that are transient (timeout/null, cURL string, 5xx, 429) go through retry_or_fail; definitive API errors (4xx) still fail immediately.
… explicit actions Resetting attempts in mark_as_success re-opened the loop: the page-view guards (attempts <= 2, failed && attempts >= 2) rely on attempts growing across requeues, so a success-with-warnings or missing-file page requeued and resubmitted on every queue tick. - Attempts are no longer reset on success or on hits. - Page-view / automatic requeues (Enqueue paths, cache_cpcss/cache_uucss without immediate/requeue) use requeue_delayed(): attempts + 1 and the same exponential backoff (future created_at) as retry_or_fail. - Explicit actions reset attempts and are eligible at once: requeue(0) for immediate/requeue args, clear_cache (save_post / manual purge), and the bulk SQL (requeue_where_status, clear_data soft, requeueJob, update*JobDataStatusWhere set attempts = 0, created_at = now) so rows in backoff do not stay ineligible after "requeue all". - The internal 'submitting' status is shown as 'waiting' in the admin (display_status), since the React UI only knows a closed status set.
Only the first id was updated (including "requeue all"). Build an IN (%d,%d,...) list from intval'd ids, pass them through prepare(), and return early on an empty list.
- clear_cache(job) no longer requeues a row that is 'submitting' (a save_post/manual purge mid-POST could flip it to queued and allow a duplicate claim). It skips and logs; the in-flight result lands normally. Chosen over a dirty marker because no existing field fits without schema change and the next change/purge requeues as usual. - The save_post/untrash/trash path (clear_on_actions -> clear_cache with throttle) skips the requeue when the row was (re)queued or claimed less than rapidload/job/save_post_min_interval minutes ago (default 10), so a WooCommerce/importer storm yields at most ~6 submissions/hour/URL. Files are kept when skipping. Manual purge and immediate/requeue args are not throttled.
…st requeue - retry_or_fail caps consecutive failures (tracked in a transient keyed by job data id + attempts) instead of lifetime attempts, so a long-lived URL whose attempts grew from page-view requeues is not failed on its first transient error. Success, explicit resets and page-view requeues end the episode. Backoff follows the retry step. - 429 with Retry-After defers the row until the API window resets without counting a retry (RapidLoad_Api now records Retry-After). Clamped 60s..1d. - save_post throttle default 60 min (<= 24 content-change submits/URL/day, matching the API per-URL limit). Throttled changes and changes arriving while a submit is in flight are no longer dropped: one deferred requeue (single WP-Cron event per job) replays clear_cache afterwards. - Titan's second CPCSS strategy claims the row from any status except 'submitting', so it is not dropped when the row is not 'queued'. - release_stale_claims deletes orphaned rows (job gone), orders by age and fires uucss/cache_cleared when giving up. - Bulk requeue SQL skips rows that are 'submitting'. - Parenthesise OR in UnusedCSS waiting/processing queries (counted and polled cpcss rows as uucss). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Production evidence: 81% of critical-CSS jobs received by the API were repeats of an earlier job for the same URL; one site submitted 1,475 jobs in 24h for 7 URLs. A failing URL was resubmitted on every queue tick (60s) with no cap and no backoff. (The API is also getting a server-side dedupe in a separate PR; this PR fixes the plugin side.)
Root causes (file:line on master)
RapidLoad_CriticalCSS_Store::update_css(~236-246) andRapidLoad_UnusedCSS_Store::update_css(~250-260) callrequeue()unconditionally onJob processing failed in queue/Error.RapidLoad_CriticalCSS_Store::purge_css(~121-150) and the UnusedCSS equivalent: a response with neitheridnorcompleted(and noerrors) is ignored, so the row staysqueuedand is resent every tick.queuedonly after the POST returns (timeout 360s,RapidLoad_Api::post); WP-Cron andrapidload_cron.phpcan overlap.RapidLoad_Job_Data::mark_as_successful_hitresetattemptson every page view (defeats any cap);RapidLoad_CriticalCSS::cleanCacheFilesusedget_success_data()with defaultLIMIT 1, so only one URL's files were kept and the rest deleted, forcing requeues.RapidLoad_UnusedCSS::cleanCacheFilesis an empty stub, so UUCSS is not affected.gmdate("Y-m-d H:m:s")(month in the minutes slot) inRapidLoad_Job_Data(x2),RapidLoad_Job,RapidLoad_Job_Optimization.Changes (one commit each)
H:m:s->H:i:s(needed for correct backoff timestamps).attempts <= 2/failed && attempts >= 2rely on attempts growing). Attempts are reset only by explicit actions:requeue(0)for immediate/requeue args andclear_cache(save_post, manual purge), and the bulk admin SQL (requeue_where_status,clear_data(soft),requeueJob,update*JobDataStatusWherenow setattempts = 0, created_at = now). Page-view/automatic requeues (both Enqueue classes,cache_cpcss/cache_uucsswithout immediate/requeue) use newrequeue_delayed(): attempts + 1 plus the same exponential backoff asretry_or_fail.cleanCacheFilespasses limit 0 toRapidLoad_CriticalCSS_DB::get_success_data; 0 means all rows, default stays 1 for other callers.RapidLoad_Job_Data::retry_or_fail(). Max attempts default 3 (RAPIDLOAD_JOB_MAX_ATTEMPTSor filterrapidload/job/max_attempts); backoff 120s, 240s, 480s... capped 1h (filterrapidload/job/retry_base_delay). Backoff is stored as a futurecreated_at(no schema change). The queued-job SELECTs (CPCSS and UUCSS) addAND (attempts IS NULL OR attempts < 1 OR created_at <= <now>). After the cap:mark_as_failed+uucss/cache_cleared.id, nocompleted, e.g.{"message":"rate limited"}) counts an attempt viaretry_or_fail, failed after the cap, for CPCSS and UUCSS. Also guards non-object results.<token>|<time>, release/refresh only by the holder,touch_lock()before every job, release also from a shutdown function so PHP Errors/fatals/exit are covered withoutThrowable;cache_cpcss/cache_uucssand the immediate/titan Store guards never requeue or re-submit a row that issubmitting; transient submit errors (null/timeout, cURL string, 5xx, 429) go throughretry_or_fail, 4xx fail at once; the admin UI seessubmittingaswaitingviadisplay_status()):claim()doesUPDATE ... SET status='submitting' WHERE id=%d AND status='queued'before the POST and skips if 0 rows.release_stale_claims()runs at the start offetch_job_id: a row insubmittingfor > 600s (longer than the 360s HTTP timeout) is treated as a failed attempt (back toqueuedwith backoff, orfailedat the cap), so a crashed run can neither strand a row nor loop forever.RapidLoad_Queue::run_task()takes anINSERT IGNOREoptions-row lock (expires after 600s) and is used by both WP-Cron andrapidload_cron.php.Worst-case submissions per URL per day (max attempts 3, base delay 120s)
failedandattempts >= 2blocks automatic requeues; only an explicit action restarts it.attempts <= 2: at most 3 resubmissions (waits 2, 4, 8 min between), then at most 1 per 24h (created_atage guard, refreshed at each requeue). So <= 3 on day one, <= 1/day afterwards.clear_on_actions->clear_cache(throttle)) skips the requeue if the row was (re)queued or claimed < 10 min ago (rapidload/job/save_post_min_interval, minutes), keeping files. At most ~6 submissions/hour/URL (144/day). Manual purge and immediate/requeue args are not throttled.Late review fixes
clear_cache(job)skips (and logs) when the row issubmitting, so a save/purge mid-POST cannot flip it back toqueuedand cause a duplicate claim. Chosen over a dirty marker: no existing field fits without a schema change, and the next change/purge requeues normally.update{Url,Rule}JobDataStatusWherebound a comma-joined id list to one%d(only the first id was updated, including "requeue all"); now buildsIN (%d,%d,...)from intval'd ids viaprepare, returns early on an empty list.Backward compatibility
??, nofinally, no scalar types);php -lclean on all changed files.created_at = now, so they stay immediately eligible. LegacyH:m:stimestamps can be at most ~12 min in the future (only matters for rows with attempts >= 1).submitting(varchar(15) fits) exists only during the POST.RapidLoad_UnusedCSS_DB::get_current_processing_tasks_job_idshas an unparenthesisedORin its WHERE.Test plan / scenarios
Verified with a stubbed harness (no WordPress available) for the Job_Data logic, plus
php -land phpcs (no new findings vs baseline):Errorrepeatedly: attempt 1 -> requeued, +120s; attempt 2 -> requeued, +240s; attempt 3 ->failed(3 submissions total).{"message":"rate limited"}: same path viaretry_or_fail; stored error is the message.mark_as_successful_hitleaves attempts unchanged (2 -> 2); success does not reset attempts either; page-view requeues stop after 3 within the first window.cleanCacheFilesnow builds the in-use list from all success rows (code review; not run against a live site).Manual to do on a staging site: point at a failing URL and watch
rapidload_job_data(attempts/created_at/status) and the API request log across several 60s ticks; runphp rapidload_cron.phpconcurrently with a WP-Cron tick and confirm one POST per row.Review follow-up (551e1d7)
Supersedes the parts above that disagree with it:
retry_or_failnow caps consecutive failures (transient keyed by job-data id + the attempts value, no schema change). Before, attempts grew with page-view requeues and never reset on success, so a long-lived URL at attempts >= 2 failed permanently on its first 5xx/timeout. Success, explicit resets and page-view requeues end the episode. Backoff follows the retry step (120s, 240s), not lifetime attempts.RapidLoad_ApirecordsRetry-After. A 429 without the header is still a counted retry.rapidload/job/deferred_requeueper job) replaysclear_cacheafter the interval, so the latest content is always regenerated.submitting(it was dropped whenever the row wasn'tqueued, e.g. after the immediate strategy succeeded or failed).release_stale_claimsdeletes orphaned rows, orders by age and firesuucss/cache_clearedwhen it gives up.submittingrows.ORinRapidLoad_UnusedCSS_DBwaiting/processing queries. It counted and polled cpcss rows as uucss.Updated worst case for the save_post row in the table: at most 1 requeue per 60 min per URL from content changes (24/day), plus at most 2 backoff retries per episode on transient errors.
Verified:
php -lclean. phpcs adds one finding vs this branch's previous head: the existinguucss/cache_clearedhook name, which is already flagged throughout the codebase. A stub harness forRapidLoad_Job_Datapassed 20/20 checks: per-episode cap, success/explicit/page-view resets, 429 deferral and clamps, 4xx/timeouts, deferred-requeue scheduling and dedupe. The failingdeploycheck is the preview environment's "Configure SSH" step (infra/secrets), unrelated to this code.