kubernetes: jitter dial backoff so builders do not retry in lockstep - #3995
kubernetes: jitter dial backoff so builders do not retry in lockstep#39951991santhu wants to merge 1 commit into
Conversation
|
Corrected the direction of the jitter in my own patch. It was equal jitter, I ran the numbers rather than just asserting the problem. 100 builders hitting the same CSR lag: without jitter all 100 dial inside the same 100ms window on every attempt, with it about 28 on the first retry and 7 by the fourth. Simulated arrival times, not something I've measured on a real cluster.
|
crazy-max
left a comment
There was a problem hiding this comment.
The direction makes sense to me.
One small wording nit: this is additive jitter, [d, 2d] capped by maxDelay, not equal jitter anymore. That behavior is fine if preserving the current exponential delay as the floor is intentional, but the description/comment should call it that directly.
Also, please squash the commits before merge.
| return d | ||
| } | ||
|
|
||
| return d + time.Duration(rand.Int63n(int64(extra)+1)) |
There was a problem hiding this comment.
The new math/rand call should use the same gosec suppression pattern as podchooser.RandomPodChooser, since retry jitter doesn't need crypto randomness:
return d + time.Duration(rand.Int63n(int64(extra)+1)) // #nosec G404 -- no strong randomness required for retry jitter…n lockstep calculateBackoff was a pure function of the attempt number, so every builder retrying the same condition waited exactly the same durations. That matters here because CSR approval lagging node readiness is a cluster-wide event: concurrent builds scheduled onto newly-ready nodes hit the transient TLS error at the same moment, then retry in unison against an API server already working through the approval backlog. Add jitter drawn from [d, 2d], capped by maxDelay, where d is the exponential value for the attempt. The exponential value is the floor rather than the midpoint, so a retry is never issued sooner than the schedule would have on its own. Centring it would let the first retry fire at baseDelay/2, which undercuts a configured minimum at exactly the wrong moment. With maxRetries=5 and baseDelay=500ms the delays used are 500ms through 4s, so the 10s cap is never reached in practice. Marked the math/rand call with the same #nosec pattern podchooser uses. Signed-off-by: Santhosh Kumar Somarapu <somarapu.santhosh91@gmail.com>
|
Thanks, all three done. Switched to the same pattern as return d + time.Duration(rand.Int63n(int64(extra)+1)) // #nosec G404 -- no strong randomness required for retry jitterYou're right about the wording. The doc comment said "backoff and jitter" without saying which kind, which is worse than useless here since the whole point of the change is that the jitter is additive rather than centred. It now says so directly: additive jitter drawing from Squashed to one commit. Verified with golangci-lint v2.8.0 built from source against Go 1.26, matching how the lint job builds it: 0 issues. |
dfa4bf9 to
c1195f0
Compare
|
@chagui if you can take a look 🙏 |
chagui
left a comment
There was a problem hiding this comment.
Left some suggestions.
I could not find any occurence of the problem in our environment but the PR is defensively reasonable 👍
| } | ||
|
|
||
| // A deterministic implementation returns a single value. The range at | ||
| // attempt 3 is two seconds wide, so one distinct value across 500 draws |
There was a problem hiding this comment.
Nit: at attempt 3, d is 4s, so the additive-jitter range is [4s, 8s], four seconds wide not two. I think this comment is left over from the earlier equal-jitter implementation. The test itself looks correct.
| // attempt 3 is two seconds wide, so one distinct value across 500 draws | |
| // attempt 3 is four seconds wide, so one distinct value across 500 draws |
| d := min(time.Duration(1<<uint(attempt))*baseDelay, maxDelay) | ||
|
|
||
| extra := d | ||
| if headroom := maxDelay - d; headroom < extra { | ||
| extra = headroom | ||
| } | ||
| if extra <= 0 { | ||
| return d | ||
| } | ||
|
|
||
| return d + time.Duration(rand.Int63n(int64(extra)+1)) // #nosec G404 -- no strong randomness required for retry jitter |
There was a problem hiding this comment.
suggestion: took me some time to understand the function, I think we could simplify the code and use more explicit names to improve readability:
| d := min(time.Duration(1<<uint(attempt))*baseDelay, maxDelay) | |
| extra := d | |
| if headroom := maxDelay - d; headroom < extra { | |
| extra = headroom | |
| } | |
| if extra <= 0 { | |
| return d | |
| } | |
| return d + time.Duration(rand.Int63n(int64(extra)+1)) // #nosec G404 -- no strong randomness required for retry jitter | |
| delay := min(time.Duration(1<<uint(attempt))*baseDelay, maxDelay) | |
| jitterRange := min(delay, maxDelay-delay) // never exceed maxDelay | |
| if jitterRange <= 0 { | |
| return delay | |
| } | |
| // Floored at delay so jitter never issues a retry sooner than | |
| // plain exponential backoff would have. | |
| return delay + time.Duration(rand.Int63n(int64(jitterRange)+1)) // #nosec G404 -- no strong randomness required for retry jitter |
| // That matters for the case this backoff exists to handle: CSR approval lagging | ||
| // node readiness is a cluster-wide event, so concurrent builds scheduled onto | ||
| // newly-ready nodes hit the transient TLS error at the same moment and would | ||
| // then retry in unison, concentrating load on the API server while it is already | ||
| // working through the approval backlog. |
There was a problem hiding this comment.
duplicates tryWithBackoff (few lines above: https://github.com/1991santhu/buildx/blob/fix%2Fkubernetes-dial-backoff-jitter/driver/kubernetes/driver.go#L316-L318) docs, attaching the TLS/CSR narrative to calculateBackoff doesn't really make sense here because calculateBackoff only needs to justify its own local choice (floor vs. midpoint).
| // calculateBackoff calculates the delay for the given attempt with exponential | ||
| // backoff and additive jitter, drawing from [d, 2d] capped by maxDelay, where d | ||
| // is the exponential value for the attempt. The exponential value is the floor | ||
| // rather than the midpoint, so a retry is never issued sooner than the schedule | ||
| // would have on its own. |
There was a problem hiding this comment.
suggestion: doc comment is much longer than this package's convention and duplicates the caller's documentation. I'd leave the rationale behind the retries to tryWithBackoff and focus on calculateBackoff contract and non-obvious implementation choice (floor vs. midpoint).
Doc comments should not explain internal details such as the algorithm used in the current implementation. Those are best left to comments inside the function body.
| // calculateBackoff calculates the delay for the given attempt with exponential | |
| // backoff and additive jitter, drawing from [d, 2d] capped by maxDelay, where d | |
| // is the exponential value for the attempt. The exponential value is the floor | |
| // rather than the midpoint, so a retry is never issued sooner than the schedule | |
| // would have on its own. | |
| // calculateBackoff returns a randomized exponential backoff delay for attempt, | |
| // never exceeding maxDelay. |
(and remove everything below this to keep it to 2 lines)
The problem
calculateBackoffis a pure function of the attempt number:so every builder retrying the same condition waits for exactly 500ms, 1s, 2s, 4s, 8s.
That matters specifically because of the condition this retry exists to handle. From the comment on
tryWithBackoff:CSR approval lag is a cluster-wide, time-correlated event rather than a per-pod accident. When a node group comes up, every build scheduled onto those nodes hits the transient TLS error at roughly the same moment — and then retries at identical instants. In CI, where parallel builds are the normal case, that concentrates retries on the API server precisely while it is still working through the approval backlog.
The backoff limits how often each builder retries. It does not stop builders retrying together.
The change
Equal jitter — retain half the computed interval as a floor, randomise the remainder:
Half is kept rather than using full jitter so a retry is never issued immediately after a TLS failure. The result never exceeds
maxDelay, so no builder waits longer than it does today — the change only removes the alignment.Testing
go test -count=2 ./driver/kubernetes/...passes,go vetclean.The package had no test file, so this adds one covering: the result stays within
[d/2, d]for attempts 0–5 across 500 samples each; a large attempt count stays capped atmaxDelay; and successive calls vary, which fails if the implementation regresses to deterministic.Note
math/randrather thancrypto/rand— this is load spreading, not a security boundary. Happy to change if the project prefers otherwise. Context for the original retry: #2668.