Skip to content

fix(scheduler): claim retry queue, rate-aware backoff, seen-on-skip, fresh rate gauges#101

Closed
luthermonson wants to merge 4 commits into
mainfrom
fix/claim-retry-resilience
Closed

fix(scheduler): claim retry queue, rate-aware backoff, seen-on-skip, fresh rate gauges#101
luthermonson wants to merge 4 commits into
mainfrom
fix/claim-retry-resilience

Conversation

@luthermonson

Copy link
Copy Markdown
Contributor

Summary

Four reliability fixes from a night of production forensics (2026-07-07/08, webhook-mode fleet sharing one GitHub App installation token):

  1. Claim retry queue (pkg/scheduler/retry_queue.go) — claim/JIT-registration failures on retryable errors (429/rate 403/5xx/network) no longer orphan jobs. Heap-based single-goroutine queue, backoff 30s/1m/2m/5m/10m with ±20% jitter, give-up after 90m with WARN, dropped on in_progress/completed webhooks. Rate-aware: when the last API response shows remaining=0 with a fresh reset timestamp, the next attempt snaps to just past the reset (jittered to prevent multi-daemon stampede). Config: [runner.claim_retry] (enabled by default, documented in config.example.toml).
  2. seen-on-skiphandleQueued recorded jobs in the dedup map only AFTER the label check, so a still-queued job with foreign labels (e.g. macos/arm64 while that host is down) was re-adjudicated at full API cost on every poll/catch-up emission — the observed 15s skipping job, OS labels don't match loop that drained the shared installation budget and starved every daemon. Label-mismatch skips now dedup like everything else.
  3. Honest startup logpolling mode enabled (tunnel disabled) printed whenever no tunnel provider existed, even in webhook mode (secret-gated); caused a real misdiagnosis. Message now reflects the actual discovery mode.
  4. Fresh rate gaugesrateTrackingTransport updates ephemerd_github_api_rate_{remaining,limit,reset_seconds,updated_seconds} from response headers on every API call, so "0" is distinguishable from "stale 0".

Test plan

  • New table-driven unit tests (fake clock): backoff ladder, jitter bounds, drop-on-completion, give-up, rate-aware snap-to-reset
  • Integration test: induced retryable failure → job claimed on retry
  • go test ./pkg/scheduler/... ./pkg/github/... green on Windows; go vet clean (GOOS=linux CGO_ENABLED=0)
  • Live soak: run one host on this branch, induce 429s during a webhook burst, verify zero orphans (planned post-merge, after the current release train)

Not covered (follow-ups)

  • Post-claim provisioning failures (Runtime.Create/VM start) — usually deterministic; same helper extends there if needed
  • ephemerd_retry_queue_depth gauge

The startup log unconditionally printed 'polling mode enabled (tunnel
disabled)' whenever no tunnel provider was configured, even when the
scheduler was running in webhook mode via an external ingress
(cfg.Webhook.Secret set). scheduler.Run gates webhook mode on
Tunnel != nil OR WebhookSecret != "", so the two paths must match.

Now: log 'webhook mode enabled (external ingress, no tunnel provider)'
when a secret is set without a tunnel, and 'polling mode enabled (no
tunnel, no webhook secret)' only when neither is configured.
…ication

handleQueued ran the canHandleJob label check BEFORE the seen/pending
dedup writes, so label-mismatched jobs (e.g. self-hosted+macos+arm64
while the Mac host is down) returned without leaving any trace. On the
next poll cycle the same queued job re-appeared and re-triggered the
skip path — logs showed 'skipping job, OS labels don't match this
platform' repeating every ~15s for hours, and every poll cycle still
paid the API cost of listing runs + listing jobs for the run.

Reorder handleQueued so the dedup check runs first; on label mismatch,
write the key into seen (10min TTL) so re-deliveries short-circuit at
the dedup gate. No pending-slot semantics change: we never took the
semaphore, so pending stays unset.

Update TestHandleQueued_SkipsMismatchedLabels to encode the new
contract (seen IS populated, pending is NOT) and add a re-delivery
assertion that pins the O(1)-per-event guarantee.
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.

@ephpm-claude ephpm-claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed in depth — thoughtful PR solving a real problem (self-healing claims), with good error classification, rate-aware backoff, jitter for the shared-token fleet, and a clean heap. Two things block merge; details inline.

Blocking: the backoff ladder and MaxAge give-up don't work on the real path

The retry loop resets its own state every time it fires:

  1. Add() creates an item (attempts=1, firstFailure=T0), scheduled at schedule[0] (~30s).
  2. fireDue() pops it and delete(q.index, it.key), then runOne() runs the handler.
  3. Handler fails → runOne calls Add(event, …) → index entry was just deleted → existed=falsefresh item, attempts=1, firstFailure=now.

So in production:

  • Ladder never advances — every retry is schedule[0] (~30s); the 1m/2m/5m/10m rungs are unreachable.
  • MaxAge give-up never firesfirstFailure resets each attempt, so now - firstFailure ≈ 0 forever. Jobs retry every 30s indefinitely instead of stopping at 90m.

The unit tests miss this because TestRetryQueue_BackoffLadder / GiveUpOnMaxAge call q.Add() directly in a loop, which finds the existing index entry and increments — bypassing the fireDue → runOne → Add cycle that runs in production.

Fix: preserve item state across a fire — e.g. don't delete from the index in fireDue (leave the item, index=-1, so the re-Add finds it, increments attempts, keeps firstFailure), and have runOne Drop(key) on success. Then add a test that drives the real Run()/fireDue loop on the fake clock and asserts the delay grows 30s→1m→2m and that the item drops after MaxAge.

Must rebase — branch predates #95/#97/#98/#99 and would regress them

mergeable_state is dirty. It was cut before today's merges:

  • Item 3 (startup log) duplicates and regresses the merged #98main already has the richer version (with the port field and a distinct tunnel = "external" case). Drop item 3 on rebase.
  • Item 2 (seen-on-skip) and the large scheduler.go hunks (incl. a ~131-line deletion near handleMacOSJob) overlap #95's handleQueued/zombie-guard and the native handler. Rebase onto current main and reconcile the seen handling with #95 so nothing is clobbered.

Minor

classifyErr: a 403 *gh.ErrorResponse returns errNonRetryable before the string fallback, so a secondary rate limit that surfaces as a plain ErrorResponse (not AbuseRateLimitError) is dropped. Low risk, but consider checking Retry-After/message on 403 first.

Good

Rate-aware snap-to-reset with jitter, sensible error taxonomy, config-gated + documented, fresh rate gauges, and the seen-on-skip idea is correct. The design is right and worth landing once the loop actually advances and it's rebased.

q.mu.Lock()
for len(q.heap) > 0 && !q.heap[0].nextAttempt.After(now) {
it := heap.Pop(&q.heap).(*retryItem)
delete(q.index, it.key)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking bug source. Deleting the index entry here means the re-enqueue in runOneAdd (line 417) can't find the item, so Add builds a fresh one with attempts=1 and firstFailure=now. Net: the backoff ladder never advances past schedule[0] and MaxAge never triggers.

Suggest: don't delete here — leave the popped item in index (with index=-1) so the re-Add at line 207 hits the existed branch and increments. Then runOne must Drop(key) on success to clean up.

return
}
// Re-enqueue on failure. Add() decides retry/give-up.
q.Add(it.event, it.handler, err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This re-enqueues by event, not by the item, so attempts/firstFailure are lost (see line 393). A test that drives the real Run()/fireDue cycle on the fake clock — asserting delay grows 30s→1m→2m and the item drops after MaxAge — would have caught this; the current ladder/give-up tests call Add() directly and bypass fireDue.

// 401/400/422 validation: fixing requires config change,
// not time.
// (Conflict 409 is handled inside claimJob by name-retry.)
return errNonRetryable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A 403 *gh.ErrorResponse returns errNonRetryable here before the string-fallback runs. A secondary rate limit that arrives as a plain ErrorResponse (not AbuseRateLimitError) would be dropped rather than retried. Consider checking Retry-After/the message on 403 before classifying non-retryable.

Comment thread cmd/ephemerd/main.go
// running webhooks via an external ingress (reverse proxy, LB, etc.).
// Scheduler treats this as webhook mode (see scheduler.Run:
// useWebhook = Tunnel != nil || WebhookSecret != "").
log.Info("webhook mode enabled (external ingress, no tunnel provider)")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates and regresses the already-merged #98: main now logs webhook mode enabled (external ingress, no managed tunnel) with the port, plus a distinct tunnel = "external" case. Drop this hunk (item 3) on rebase so #98's richer messages aren't reverted.

@ephpm-claude

ephpm-claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Superseded by the rebased + bug-fixed successor. The review findings are addressed there: the fireDue/runOne state-reset bug (ladder stuck at 30s / MaxAge never firing) is fixed with a regression test that drives the real fire path, the branch is rebased onto current main, and the startup-log edit (item 3) is dropped since it regressed the merged tunnel PR. Closing this in favor of that.

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