fix(spur-k8s): fail fast on a controller that does not answer - #854
Open
pre wants to merge 1 commit into
Open
Conversation
This was referenced Sep 8, 2026
The operator's channel to spurctld had no bound of any kind, so two things stalled it for minutes. When the operator started before the controller was ready, the client Service had no endpoint and the SYN was dropped; the connect hung for the kernel's SYN retry time, more than two minutes, before the task failed and its retry loop opened a new connection. The nodes reached the controller about three minutes after it was ready. When the controller Pod behind the job controller's open channel was killed, every RPC on that channel waited for the kernel to give up on the peer, and a SpurJob created in that window was submitted only a minute or two later. The four places that opened a channel now share one helper. It sets a 10 s connect timeout, HTTP/2 keepalive pings, and a 30 s bound on each request. The bound alone does not close the dead connection, and the next request would wait on it again, so the job controller's long-lived client drops its channel after a transport error and opens a new one on the next call. A test opens a channel to a socket that accepts and never answers, on tokio's paused clock, and checks that the call fails within the bounds and that the channel is dropped. The node watcher also ends on a refused registration instead of logging it and keeping the node's fingerprint, which made the node wait for its next event before it was tried again. Signed-off-by: Petrus Repo <petrus.repo@amd.com>
pre
force-pushed
the
pr/k8s-operator-registration
branch
from
September 8, 2026 12:16
52f9f25 to
14ad737
Compare
pre
marked this pull request as ready for review
September 8, 2026 12:19
pre
requested review from
biluriuday,
sajmera-pensando,
sgopinath1,
shiv-tyagi and
yansun1996
as code owners
September 8, 2026 12:19
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.
What this fixes
The operator's channel to
spurctldhad no bound of any kind, and two things stalled it for minutes on a real cluster.The first registration of the nodes came three minutes late. On a new cluster the operator starts before the controller is ready, when the client Service has no endpoint. The SYN is dropped and the connect hangs for the kernel's SYN retry time, more than two minutes, before it fails with a transport error. Only then does the task's retry loop open a new connection, which succeeds at once, and the registration follows within milliseconds. Every
SpurJobsubmitted in that window waited for a node that did not exist. From the operator log:A killed controller Pod stalled the job controller for a minute or two. The job controller keeps one channel open. When the Pod behind it was killed, the packets to that peer were dropped and every RPC on the channel waited for the kernel to give up on it. A
SpurJobcreated meanwhile was submitted only when the connection finally failed, 70 to 110 s after the kill. This is the failure oftest_new_leader_accepts_writesafter a leader kill:Approach
One helper,
controller::connect, opens the channel with a 10 s connect timeout, HTTP/2 keepalive pings, and a 30 s bound on each request. No RPC on this channel streams. The node watcher, the job controller, the heartbeat sender and the readiness probe all use it; each had its own copy of the URL prefixing and the connect.The request bound alone was not enough: it cancels the request but leaves the dead connection in place, and the next request waits on it again, 30 s at a time. The job controller's long-lived client is now a small wrapper,
ControllerClient, that drops its channel after a transport error (UNAVAILABLE,UNKNOWN,CANCELLED,DEADLINE_EXCEEDED) and connects again on the next call. A refusal such asNOT_FOUNDis an answer and keeps the channel. The heartbeat sender already connects per tick, and the node watcher restarts through its retry loop.The node watcher also ends on a refused registration instead of logging it. It stored the node's resource fingerprint before the call, so a node whose first registration failed was not tried again until its resources changed or the watcher restarted for another reason. Returning the error restarts the watcher through the existing retry loop, which lists every node again; a repeat registration is accepted. The fingerprint is stored only after the registration succeeds.
Testing
cargo clippy --workspace --exclude spur-ffi --all-targets --lockedandcargo test --lockedare clean. Unit tests cover the URL prefixing, the transport-error classification, and that the wrapper drops its channel after a transport error and keeps it after a refusal. One more test opens a channel to a socket that accepts the connection and never answers, which is what a killed Pod looks like until the kernel gives up, and runs the call on tokio's paused clock so the real bounds apply without waiting: the call fails withUNAVAILABLE: http2 error, the keepalive having closed the connection before the request bound, and the channel is dropped. The connect timeout has no unit seam, because loopback cannot drop a SYN; it is verified on a cluster.On a three-node k0s cluster built by
spur k8s up, with an image carrying this change:Cluster runs with
test_raft_ha.pyThree-node k0s cluster built by
spur k8s up, one control plane and two workers, the e2e harness of #853, runs back to back on the same cluster. The operator build names which part of this change the image carried; the controller image was the same throughout.test_new_leader_accepts_writestest_new_leader_accepts_writestest_state_survives_leader_failover,test_new_leader_accepts_writes404 page not found(fixed in #853)test_new_leader_accepts_writestest_state_survives_leader_failover,test_new_leader_accepts_writescannot reach leaderduring the election, and the job controller's error backoff had doubled to 32 s; not covered by this change, see belowTwo focused runs of
test_state_survives_leader_failoverandtest_new_leader_accepts_writesalone with the keepalive build: one failed on the terminating CRD, one passed 2 of 2.The dead-channel failure, runs 1, 3 and 5, did not appear again once the channel is dropped after a transport error. The remaining failures are the harness race, fixed in #853, and the backoff after a Pod kill:
One thing this change does not cover, seen in run 9: right after a Pod kill, a follower answers
cannot reach leaderwhile the election runs, and the job controller's own error backoff doubles to 32 s and beyond, so aSpurJobcreated in that moment is submitted after the test's 60 s window. That is a separate matter of the backoff and is left for a follow-up.Related: