Skip to content

fix(scheduler): claim retry queue with jittered backoff + fresh rate gauges#104

Merged
luthermonson merged 2 commits into
mainfrom
fix/claim-retry-queue-v2
Jul 9, 2026
Merged

fix(scheduler): claim retry queue with jittered backoff + fresh rate gauges#104
luthermonson merged 2 commits into
mainfrom
fix/claim-retry-queue-v2

Conversation

@luthermonson

Copy link
Copy Markdown
Contributor

Rebase of the surviving pieces of closed #101 onto a moved main (post #95-#99).

What's in

  • Claim retry queue (pkg/scheduler/retry_queue.go + tests + scheduler.go hooks + [runner.claim_retry] config + main.go wiring incl. buildRateHint). Fixes queued jobs being lost forever when the initial claim / JIT registration hits a transient error (webhooks are not re-delivered). Jittered ladder (30s/1m/2m/5m/10m, MaxAge 90m), rate-aware snap-to-reset when GitHub reports remaining=0.
  • Fresh GitHub rate-limit gauges (pkg/github/rateTrackingTransport + pkg/metrics). ephemerd_github_api_rate_remaining was defined but never written; now parsed from every response (success and error) plus companion _limit, _reset_seconds, _updated_seconds gauges for staleness detection. Provider.RateSnapshot() feeds the retry queue's rate-aware backoff.

What's dropped from #101

Conflicts resolved

Validation

Closes issues fixed by original #101 (dropped webhook-job claim failures; stale rate gauges).

ephemerd_github_api_rate_remaining was defined but never written, so it
sat at 0 forever. Operators looking at Grafana couldn't tell 'budget
truly exhausted' from 'no data yet' from 'stale reading after window
reset'.

Wrap the GitHub HTTP client in a rateTrackingTransport that parses
X-RateLimit-{Limit,Remaining,Reset} on EVERY response (200 or 429/5xx
error — errors carry rate headers too, and that's exactly when we most
need them). Introduce three companion gauges:

  ephemerd_github_api_rate_limit           (ceiling for the window)
  ephemerd_github_api_rate_reset_seconds   (unix ts of next reset)
  ephemerd_github_api_rate_updated_seconds (when we last observed)

Use rate_updated_seconds vs time() to age out a stale reading in
dashboards / alerts.

Also expose Client.RateSnapshot() for callers that want to bias
retry backoff based on the live rate state (used by the upcoming
scheduler claim-retry queue).

Guard against responses that don't emit rate headers (some GraphQL
paths) — those must NOT clobber the last known value to zero.
…re scheduling

GitHub does not re-deliver workflow_job webhooks. When ephemerd's
initial claim/JIT-runner-registration attempt failed with a transient
error (rate-limit exhausted, transient 5xx, network blip), the queued
job was dropped forever - operators noticed only via missing runs and
manually re-queued each job by hand.

Add an in-memory priority-heap retry queue keyed by jobKey. Failed
claims are re-scheduled on a jittered ladder (30s, 1m, 2m, 5m, 10m by
default), giving up after MaxAge (default 90m).

Hooks:
- handleLocalJob, handleLinuxJob, handleMacOSJob classify the claim
  error via classifyErr and enqueue on retryable failures. The old
  blind time.Sleep(backoffDuration) and 5s sleep are gone - the sem is
  released first so we do not hold a slot idle across the wait.
- handleCompleted Drops the retry key so a job picked up elsewhere
  (peer daemon sharing the installation token, or manual cancel) stops
  getting reattempted.
- The event loop also Drops on 'in_progress', matching the same intent.

Rate-aware backoff: when the last GitHub API response reported
remaining=0 with a fresh (< 5m old) update timestamp and reset > now,
the next attempt is snapped to reset + [5s, 25s) jitter. This avoids
burning attempts against a provably-exhausted budget, and the jitter
prevents multiple daemons sharing the installation token from
stampeding at the exact same second.

Error classification (classifyErr) sorts errors into
non_retryable / rate_limit / server_side / network / unknown_retryable.
404/422/401/permission-denied are non-retryable; 429 and the go-github
RateLimitError/AbuseRateLimitError typed errors are rate_limit; 5xx
plus string-match fallbacks ("bad gateway", "gateway timeout") are
server_side; net.Error is network. Unknown errors are treated as
retryable within backoff/MaxAge - the whole point is to stop losing
jobs to unexpected transient failures.

Wiring:
- New RetryConfig on scheduler.Config, plumbed from [runner.claim_retry]
  in the TOML (enabled defaults to true - losing jobs is worse than
  extra retries).
- RateHint closure in cmd/ephemerd/main.go walks activeProviders for a
  GitHub provider and binds to its RateSnapshot; nil when no rate-aware
  provider is present.
- providers/github.Provider exposes RateSnapshot() forwarding to the
  underlying github.Client (added in the earlier rate-transport commit).

Tests: retry_queue_test covers backoff progression (schedule ladder,
clamp past end), MaxAge give-up, Drop on completion, non-retryable
classification, rate-aware snap-to-reset with a fake clock, and the
full classifyErr table. retry_integration_test uses the existing
claimErrorProvider to end-to-end verify that a retryable failure in
handleLocalJob puts one entry in the queue and that a completed webhook
drops it. All new tests use table-driven or deterministic clock
patterns; no time.Sleep in scheduling logic.
@luthermonson luthermonson merged commit f9e8b63 into main Jul 9, 2026
1 check failed
@luthermonson luthermonson deleted the fix/claim-retry-queue-v2 branch July 9, 2026 06:43
ephpm-claude Bot added a commit that referenced this pull request Jul 11, 2026
…106)

The claim retry queue merged in #104 has three correctness bugs; fix
them in place (config-gated and enabled by default, so this runs on
every fleet host):

1. Backoff ladder never advances / give-up never fires. fireDue deleted
   the popped item from the index, so runOne's failure re-Add built a
   fresh item — attempts reset to 1 (stuck at schedule[0] ~30s) and
   firstFailure reset every fire (MaxAge never triggered). A job that
   couldn't be claimed retried every 30s forever, burning the shared
   installation token it was waiting on. Fix: fireDue keeps the item in
   the index across a fire.

2. Retries dropped after one attempt. With (1) half-fixed you get the
   opposite: retryHandler always returned nil, and runOne dropping on
   nil removed the item handleQueued had just re-Added synchronously —
   so a job self-healed at most once. Fix: retryHandler returns the real
   claim error (captured via a context pointer that enqueueRetryIfEligible
   fills in, which also suppresses the duplicate enqueue). runOne drops
   on success / advances on failure, with the rate-limit error class
   preserved so snap-to-reset still applies on later attempts.

3. Native macOS claim failures didn't self-heal — handleNativeMacOSJob
   was the one claim path not wired to the queue (Windows already routes
   through handleLocalJob). Wired it up.

Tests: TestRetryQueue_StillFailingRetryStaysEnqueued drives the real
fireDue->retryHandler->claim path (fails before this change);
TestRetryQueue_LadderAdvancesThroughFirePath asserts the delay grows
30s->1m->2m and firstFailure is preserved. Full scheduler suite green,
-race and go vet clean.


Claude-Session: https://claude.ai/code/session_01ChVRC9ZrvQarrctDwSy1S5

Co-authored-by: Luther Monson <luther.monson@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant