Skip to content

Fix job resubmission loop: retry cap, backoff, atomic claim - #234

Merged
azeezs2012 merged 14 commits into
masterfrom
fix/job-requeue-loop
Oct 12, 2026
Merged

azeezs2012 merged 14 commits into
masterfrom
fix/job-requeue-loop

Conversation

@shakee93

@shakee93 shakee93 commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

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)

  • A. RapidLoad_CriticalCSS_Store::update_css (~236-246) and RapidLoad_UnusedCSS_Store::update_css (~250-260) call requeue() unconditionally on Job processing failed in queue / Error.
  • B. RapidLoad_CriticalCSS_Store::purge_css (~121-150) and the UnusedCSS equivalent: a response with neither id nor completed (and no errors) is ignored, so the row stays queued and is resent every tick.
  • C. No atomic claim: the row leaves queued only after the POST returns (timeout 360s, RapidLoad_Api::post); WP-Cron and rapidload_cron.php can overlap.
  • D. RapidLoad_Job_Data::mark_as_successful_hit reset attempts on every page view (defeats any cap); RapidLoad_CriticalCSS::cleanCacheFiles used get_success_data() with default LIMIT 1, so only one URL's files were kept and the rest deleted, forcing requeues. RapidLoad_UnusedCSS::cleanCacheFiles is an empty stub, so UUCSS is not affected.
  • E. gmdate("Y-m-d H:m:s") (month in the minutes slot) in RapidLoad_Job_Data (x2), RapidLoad_Job, RapidLoad_Job_Optimization.

Changes (one commit each)

  1. Date format H:m:s -> H:i:s (needed for correct backoff timestamps).
  2. Attempts are never reset by hits or by success (resetting on success re-opened the loop for success-with-warnings / missing-file pages, because the page-view guards attempts <= 2 / failed && attempts >= 2 rely on attempts growing). Attempts are reset only by explicit actions: requeue(0) for immediate/requeue args and clear_cache (save_post, manual purge), and the bulk admin SQL (requeue_where_status, clear_data(soft), requeueJob, update*JobDataStatusWhere now set attempts = 0, created_at = now). Page-view/automatic requeues (both Enqueue classes, cache_cpcss/cache_uucss without immediate/requeue) use new requeue_delayed(): attempts + 1 plus the same exponential backoff as retry_or_fail.
  3. cleanCacheFiles passes limit 0 to RapidLoad_CriticalCSS_DB::get_success_data; 0 means all rows, default stays 1 for other callers.
  4. Retry cap + backoff: new RapidLoad_Job_Data::retry_or_fail(). Max attempts default 3 (RAPIDLOAD_JOB_MAX_ATTEMPTS or filter rapidload/job/max_attempts); backoff 120s, 240s, 480s... capped 1h (filter rapidload/job/retry_base_delay). Backoff is stored as a future created_at (no schema change). The queued-job SELECTs (CPCSS and UUCSS) add AND (attempts IS NULL OR attempts < 1 OR created_at <= <now>). After the cap: mark_as_failed + uucss/cache_cleared.
  5. Unexpected purge response (no id, no completed, e.g. {"message":"rate limited"}) counts an attempt via retry_or_fail, failed after the cap, for CPCSS and UUCSS. Also guards non-object results.
  6. Atomic claim + lock (hardened in follow-up commits: unique lock token <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 without Throwable; cache_cpcss/cache_uucss and the immediate/titan Store guards never requeue or re-submit a row that is submitting; transient submit errors (null/timeout, cURL string, 5xx, 429) go through retry_or_fail, 4xx fail at once; the admin UI sees submitting as waiting via display_status()): claim() does UPDATE ... SET status='submitting' WHERE id=%d AND status='queued' before the POST and skips if 0 rows. release_stale_claims() runs at the start of fetch_job_id: a row in submitting for > 600s (longer than the 360s HTTP timeout) is treated as a failed attempt (back to queued with backoff, or failed at the cap), so a crashed run can neither strand a row nor loop forever. RapidLoad_Queue::run_task() takes an INSERT IGNORE options-row lock (expires after 600s) and is used by both WP-Cron and rapidload_cron.php.

Worst-case submissions per URL per day (max attempts 3, base delay 120s)

Path Bound
API error (poll returns Error / Job processing failed, or unexpected/5xx/timeout submit response) 3 submissions total (attempt 1 -> wait 2 min, attempt 2 -> wait 4 min, attempt 3 -> failed). Then terminal: failed and attempts >= 2 blocks automatic requeues; only an explicit action restarts it.
Success but not injected (warnings) Page-view requeue allowed while attempts <= 2: at most 3 resubmissions (waits 2, 4, 8 min between), then at most 1 per 24h (created_at age guard, refreshed at each requeue). So <= 3 on day one, <= 1/day afterwards.
CPCSS success but file missing (cache dir not writable) Same guard and same bound as above (<= 3, then <= 1/day).
save_post / importer storm Throttled: the content-change path (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 is submitting, so a save/purge mid-POST cannot flip it back to queued and 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}JobDataStatusWhere bound a comma-joined id list to one %d (only the first id was updated, including "requeue all"); now builds IN (%d,%d,...) from intval'd ids via prepare, returns early on an empty list.

Backward compatibility

  • No DB schema change, no new options (lock is a transient-style options row, removed after each run), no API contract change.
  • PHP 5.4/5.6 compatible syntax (no ??, no finally, no scalar types); php -l clean on all changed files.
  • Existing rows: attempts 0/NULL are unaffected; user/page-view requeues set created_at = now, so they stay immediately eligible. Legacy H:m:s timestamps can be at most ~12 min in the future (only matters for rows with attempts >= 1).
  • New transient status submitting (varchar(15) fits) exists only during the POST.
  • Out of scope, noticed: RapidLoad_UnusedCSS_DB::get_current_processing_tasks_job_ids has an unparenthesised OR in its WHERE.

Test plan / scenarios

Verified with a stubbed harness (no WordPress available) for the Job_Data logic, plus php -l and phpcs (no new findings vs baseline):

  • (a) poll returns Error repeatedly: attempt 1 -> requeued, +120s; attempt 2 -> requeued, +240s; attempt 3 -> failed (3 submissions total).
  • (b) POST returns {"message":"rate limited"}: same path via retry_or_fail; stored error is the message.
  • (c) two claims on the same row: first true, second false (second run does no POST).
  • (d) mark_as_successful_hit leaves attempts unchanged (2 -> 2); success does not reset attempts either; page-view requeues stop after 3 within the first window.
  • Lock: second runner is refused; a stale lock is taken over; the old holder's release does not delete the new holder's lock (stub harness).
  • (e) cleanCacheFiles now 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; run php rapidload_cron.php concurrently with a WP-Cron tick and confirm one POST per row.

Review follow-up (551e1d7)

Supersedes the parts above that disagree with it:

  • Retry cap is per failure episode. retry_or_fail now 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.
  • 429 + Retry-After (rapidload-api#4): the row is deferred until the API window resets (clamped 60s..1 day) and the retry cap is not used. RapidLoad_Api records Retry-After. A 429 without the header is still a counted retry.
  • save_post throttle: 60 min by default (was 10), so content changes produce at most 24 submits per URL per day, matching the API per-URL limit. Throttled changes, and changes made while a submit is in flight, are no longer dropped: one deferred requeue (a single WP-Cron event rapidload/job/deferred_requeue per job) replays clear_cache after the interval, so the latest content is always regenerated.
  • Titan second CPCSS strategy claims the row from any status except submitting (it was dropped whenever the row wasn't queued, e.g. after the immediate strategy succeeded or failed).
  • release_stale_claims deletes orphaned rows, orders by age and fires uucss/cache_cleared when it gives up.
  • Bulk requeue SQL skips submitting rows.
  • Fixed the unparenthesised OR in RapidLoad_UnusedCSS_DB waiting/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 -l clean. phpcs adds one finding vs this branch's previous head: the existing uucss/cache_cleared hook name, which is already flagged throughout the codebase. A stub harness for RapidLoad_Job_Data passed 20/20 checks: per-episode cap, success/explicit/page-view resets, 429 deferral and clamps, 4xx/timeouts, deferred-requeue scheduling and dedupe. The failing deploy check is the preview environment's "Configure SSH" step (infra/secrets), unrelated to this code.

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.
@vercel

vercel Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
autoptimize-unusedcss Ignored Ignored Oct 12, 2026 5:55am UTC

Request Review

shakee93 and others added 8 commits October 11, 2026 21:01
…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>
@azeezs2012
azeezs2012 merged commit 4a2ce71 into master Oct 12, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants