Skip to content

fix(checkworker): stop the rate limiter starving long-period checks - #609

Merged
fclairamb merged 1 commit into
mainfrom
fix/rate-limit-long-period-starvation
Oct 19, 2026
Merged

fclairamb merged 1 commit into
mainfrom
fix/rate-limit-long-period-starvation

Conversation

@fclairamb

Copy link
Copy Markdown
Owner

Problem

The prod watchdog reported two hourly SSL checks of one org showing "No data" for 27 hours while their region was healthy.

The gravelines worker logged Check execution rate-limited; deferring to next period (limit 10) at every hourly attempt. The org schedules about 10.5 checks/min against the SaaS cap of 10/min.

The fair rotation of spec 2026-08-26-02 keeps a deferred job's effective_scheduled_at, so it wins the next contended claim. That works for 1-minute checks (they all ran 1139 to 1426 times in 24h). An hourly check gets one attempt per hour, always at the same second. Those two land one second after a burst of 1-minute siblings has drained the bucket, so they lose every time.

Fix

  • A job the gate already deferred (CheckJob.RateLimitDeferred: scheduled_at about a period ahead of effective_scheduled_at) may borrow one token.
  • The bucket goes into debt, down to one burst below zero. The next on-time arrivals are deferred instead, so the deficit keeps rotating.
  • Refill repays the debt, so the org's long-run rate stays at the cap.
  • Both gates (in-process worker and agent claim) call the new entitlements.ReserveJobExecution.

Tests

  • TestDeferredJobBorrowsSoLongPeriodChecksRun simulates 12 hours of the prod org second by second, on the real bucket, with the phases read from check_jobs.
    • Control without borrowing: the hourly check runs 0 times, and a 5-minute check runs 2 times out of 144.
    • With the fix: every check runs at least every other period (hourly 6/12, that 5-minute check 73/144). The total stays at or below the cap: 7206 runs against a ceiling of 7220.
  • TestReserveDeferredCheckExecutionDebtIsBounded covers the debt floor and its repayment.
  • TestCheckJobRateLimitDeferred covers the predicate: on time, cost offset, tier credit, deferred.

Under extreme overload (demand far above the cap) the debt floor can still be reached, and then the same arrival-time lottery applies. That is out of scope here.

🤖 Generated with Claude Code

A rate-limited job is deferred by a full period and keeps its old
effective_scheduled_at, so it wins the next contended claim. That rotates
the deficit among 1-minute checks, but an hourly check gets one attempt
per hour, always at the same UID-hash second. When frequent siblings drain
the org's bucket just before that second, claim priority is worth nothing
and the check never runs again.

Seen in prod: an org at 10.5 checks/min against a 10/min cap had two
hourly TLS checks with no result for 27 hours.

A job the gate already deferred may now borrow one token, taking the
bucket into debt down to one burst below zero. The next on-time arrivals
are deferred instead, so the deficit keeps rotating, and refill repays the
debt so the long-run rate stays at the cap. Both gates (in-process worker
and agent claim) go through the new ReserveJobExecution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.36%. Comparing base (2a8b86d) to head (b071a91).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #609      +/-   ##
==========================================
- Coverage   83.36%   83.36%   -0.01%     
==========================================
  Files        1238     1238              
  Lines      127020   127026       +6     
==========================================
+ Hits       105894   105896       +2     
- Misses      21117    21121       +4     
  Partials        9        9              
Files with missing lines Coverage Δ
server/internal/checkworker/worker.go 90.46% <100.00%> (ø)
server/internal/db/models/check_job.go 100.00% <100.00%> (ø)
server/internal/entitlements/service.go 88.69% <100.00%> (+0.46%) ⬆️
server/internal/handlers/agentws/handler.go 83.39% <100.00%> (ø)

... and 8 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@fclairamb
fclairamb merged commit 3295a20 into main Oct 19, 2026
34 checks passed
@fclairamb
fclairamb deleted the fix/rate-limit-long-period-starvation branch October 19, 2026 10:43
fclairamb added a commit that referenced this pull request Oct 19, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.40.1](v0.40.0...v0.40.1)
(2026-10-19)


### Bug Fixes

* **checkworker:** stop the rate limiter starving long-period checks
([#609](#609))
([3295a20](3295a20))
* **dash0:** style the HTTP timing chart like the response time chart
([#611](#611))
([c5264d2](c5264d2))
* **deps:** update dependency @hookform/resolvers to v6
([#610](#610))
([8474852](8474852))
* **deps:** update go dependencies (non-major)
([#612](#612))
([45512d4](45512d4))
* **deps:** update module github.com/arran4/golang-ical to v0.3.8
([#605](#605))
([8c5ef41](8c5ef41))
* **deps:** update module github.com/fergusstrange/embedded-postgres to
v2 ([#613](#613))
([0f1b50c](0f1b50c))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant