fix(doctor): budget the retry envelope in the listener acknowledgement grace - #177
Merged
Merged
Conversation
…t grace The doctor's listener-health grace window budgeted a single request timeout plus two reconcile intervals, with no retry allowance at all, while observedFreshnessLimit -- a sibling bound over the same GitHub request path in the same file -- budgets the full retry envelope. That asymmetry is why benign busy-fleet acknowledgement lag degraded the doctor exit code: under load those requests retry, and the window had no room for even one backoff. The window now budgets one full request-retry envelope for each of the two request paths an acknowledgement crosses -- advertising capacity, then reading back the state that acknowledges it -- plus two reconcile intervals, reusing the existing saturating helper so overflow behavior stays one implementation. Sizing sits deliberately between the old window and the freshness limit, whose full attempt-and-target budget would be too loose to preserve this check's defect-signal value. Sustained beyond-grace lag remains a non-advisory hard fault: a wedged listener never acknowledges, so its lag grows monotonically past any bounded window. Calibrated against the single benign observation on record (2m9s, the ci-runner-alignment audit's D4 finding), which the new window contains with roughly 2x headroom. That observation is now pinned as a test case rather than left implied by the boundary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a2ebcf4ed8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…licy The previous derivation's comment claimed each convergence leg got "one full retry envelope", but the code passed the leg count into saturatingFreshnessDuration's *attempts* parameter, so each leg was budgeted a single attempt plus a single backoff. With the tested defaults that made the window 4m30s while one leg's configured retry sequence can legitimately run six 70-second attempts separated by five backoffs. It also sized those backoffs from bare Retry.Maximum, ignoring that BackoffPolicy.delay applies jitter after capping the base delay, so a policy-compliant wait can reach Maximum*(1+JitterRatio). Nothing else in the doctor notices a poll that is still legitimately retrying: Reconciler.pollCheckpoint keeps writing observed state on the reconcile cadence while a poll is open, so the heartbeat stays inside observedFreshnessLimit while the pool transition timestamp this age measures stays pinned. An undersized window therefore hard-faults a listener mid-retry, which is the failure this change set exists to remove. Budget the complete envelope per leg -- MaxAttempts attempts of RequestTimeout plus maxJitteredBackoff -- taking the window from 4m30s to 26m10s on the tested defaults, and 28m34s at the default 0.2 jitter ratio. Drop the assertion that the grace stays under observedFreshnessLimit. That was a proxy for "do not make this too loose", and it compares unrelated quantities: the grace bounds one pending transition, which legitimately spans several reconciles and several retry envelopes, while the freshness limit bounds heartbeat staleness over a value pollCheckpoint refreshes every reconcile interval. The derivation is pinned directly instead, with a case per retry term proving each one widens the window. The cost is detection latency, now scaling with the configured retry policy. Detection is unchanged: a wedged listener never acknowledges, so its lag grows monotonically past any bounded window and still hard-faults. Extract maxJitteredBackoff, until now a local in reconcileStepTimeout, so the jitter-aware backoff bound has one implementation instead of a second copy here. observedFreshnessLimit deliberately keeps sizing its waits from bare Retry.Maximum: that gap predates this branch, and widening the heartbeat window is a behavior change outside this fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5KaGv34aWCL1epZMSiupC
TestListenerAcknowledgementGraceIsIndependentOfObservedFreshness asserted grace > freshness under a name claiming the two are independent, and the doc comment directly above it says the bounds carry no ordering against each other. That is the same comment-versus-code mismatch this branch exists to fix, one commit later. It was also pinning a coincidence rather than a property: the inequality holds only because there are two convergence legs. At one leg the two bounds are equal and the assertion fails, even though nothing about either bound would be wrong. Delete it. Replacing the old under-the-freshness-limit proxy with its inverse adds no coverage: the exact-value test already pins the derivation completely, and the per-retry-term subtests cover what the two bounds actually have to do. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5KaGv34aWCL1epZMSiupC
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.
Summary
Implements #100 / audit finding D4 ("widen grace, keep hard fault").
The root cause turned out to be sharper than "the window is too small", so the
fix is a derivation change rather than a bigger multiplier.
listenerAcknowledgementGracebudgeted one request timeout plus two reconcileintervals, with no retry allowance at all. Twenty lines further down the same
file,
observedFreshnessLimitcomputes a bound over the same GitHub requestpath and budgets a full retry envelope. That asymmetry is the defect: a busy
fleet is precisely when those requests retry, and the acknowledgement window had
no room for even one backoff. Benign busy-fleet lag exceeding it was the
predictable consequence, not a surprise.
The window now budgets the complete retry envelope for each of the two request
paths an acknowledgement crosses — the controller advertising the new
capacity, and a later reconcile reading back the pool state that acknowledges it
— plus two reconcile intervals. One envelope is
github.retry.maxAttemptsattempts, each capped at
github.requestTimeout, separated by backoff waits ofgithub.retry.maximum× (1 +github.retry.jitterRatio). On the tested configshape that takes the window from 4m30s to 26m10s, and to 28m34s at
DefaultBackoffPolicy's 0.2 jitter ratio.It has to be the complete envelope rather than a sample of it, because nothing
else in the doctor notices a poll that is still legitimately retrying:
Reconciler.pollCheckpointkeeps writing observed state on the reconcile cadencewhile a poll is open, and deliberately preserves the pool transition timestamp
this age measures. So the heartbeat stays fresh inside
observedFreshnessLimitwhile the measured age grows monotonically through the poll. A window shorter
than the retry policy hard-faults a listener mid-retry.
Two supporting changes:
saturatingFreshnessDurationnow takes a single composedretryUnitscountinstead of separate attempt and target multipliers, so a caller composing it
from a different pair of factors does not have to pass one of them through a
parameter named for the other. Overflow saturation stays one implementation.
maxJitteredBackoffis extracted fromreconcileStepTimeout(value-identicalthere). Jitter is applied after the backoff base is capped at
Retry.Maximum, so a policy-compliant wait can reachMaximum × (1 + JitterRatio); that bound now has one implementation insteadof a second copy in
doctor.go.observedFreshnessLimitdeliberately still sizes its waits from bareRetry.Maximum. That gap predates this branch, and widening theheartbeat-staleness window is a behavior change outside this fix — the extracted
helper makes closing it a one-line change whenever it is taken on deliberately.
The hard fault is unchanged. A wedged listener never acknowledges, so its lag
grows monotonically past any bounded window; widening delays that verdict, it
does not remove it. That is pinned by a dedicated test rather than left to
inspection. The cost of the wider window is detection latency, which now scales
with the configured retry policy.
On sizing, honestly
The issue asks for the new derivation to be picked "against observed busy-fleet
lag distributions, not a guess". There is no distribution — the audit record
(D4) contains exactly one benign observation, 2m9s, ~1.6x the old window. So:
number of seconds and not to that single observation. That is what keeps it
from being a guess: the bound answers "how long can a legitimate convergence
take, given this config", which is answerable from the config alone.
that derivation, not as its calibration target. Nothing here is tuned to 2m9s.
property rather than a figure in a report.
Flagging that explicitly rather than implying a distribution was consulted.
Test plan
Full suite green on Windows:
go build ./...,go vet ./...,gofmt -l ./internal/(clean),golangci-lint run --config .golangci.yml ./...(0 issues.),go test ./... -count=1(all packages ok, 0 failures).Measured values, not hand-derived: grace
4m30s → 26m10sat jitter 0 and28m34sat jitter 0.2;observedFreshnessLimitunchanged at13m10s.New and changed coverage:
TestListenerAcknowledgementGraceBudgetsAFullRetryEnvelopePerConvergenceLegpins the derivation exactly, then asserts a widened window for each term
of the retry policy —
maxAttempts,retry.maximum,jitterRatio,requestTimeout— as separate subtests. ThemaxAttemptsandjitterRatiocases are the regression tests for the specific defect: the previous
derivation ignored both.
TestDoctorAllowsOnlyBoundedListenerAcknowledgementTransitionkeeps thebenign-busy-fleet-lagcase at exactly the observed 2m9s, and itsgrace-boundary cases (
at-grace,past-grace) now derive fromlistenerAcknowledgementGracerather than hardcoding seconds, so a futurere-derivation cannot leave a boundary case silently asserting the wrong side
of the window.
TestListenerAcknowledgementStaysAHardFaultForAWedgedListenerdrives alistener at 10x grace and asserts
ExitDegradedplus the[FAIL]marker, sowidening cannot silently turn the check advisory. It scales with the derivation.
observedFreshnessLimit.The two bound unrelated quantities — one pending transition spanning several
reconciles and several retry envelopes, versus heartbeat staleness over a value
pollCheckpointrefreshes every reconcile interval — so neither constrains theother, and an ordering assertion would only pin a coincidence of the current
leg count.
Revert-proof: restoring the previous derivation fails the exact-value assertion,
the
maxAttemptsandjitterRatiosubtests, and theat-gracecase — so thenew coverage genuinely discriminates the fix rather than passing either way.
Not run locally: the race detector (
-racerequires cgo, unavailable on thishost). CI's
go-qualitylane covers it.Related
Fixes #100.
fault) — the source of the 2m9s observation and of the constraint that
sustained beyond-grace lag stays non-advisory.
docs/observability.mdrestated the old formula in two places; both areupdated, since a doc that names a derivation goes stale the moment the
derivation changes.
🤖 Generated with Claude Code
https://claude.ai/code/session_01W5KaGv34aWCL1epZMSiupC