Skip to content

fix(controller): bound CRD watch timeout to stop reflector staleness on AKS (#451)#456

Merged
pallakatos merged 1 commit into
mainfrom
fix/451-controller-watch-staleness
Jun 26, 2026
Merged

fix(controller): bound CRD watch timeout to stop reflector staleness on AKS (#451)#456
pallakatos merged 1 commit into
mainfrom
fix/451-controller-watch-staleness

Conversation

@pallakatos

Copy link
Copy Markdown
Collaborator

Fixes #451.

Problem

On AKS, CR spec edits (InferencePolicy / ToolPolicy / EgressApproval / …) intermittently stopped reaching the compiled ConfigMap until a controller restart. The reconciler kept recompiling the pre-edit object on its 15s requeue (frozen version_hash/compiled_digest) despite fresh metadata.generation — i.e. reconciling fresh metadata against a stale reflector store.

Root cause (corrected & verified)

Every reconciler used watcher::Config::default(). That is not "no timeout" — kube-rs sends timeoutSeconds=290 by default. The problem is the value:

  • Azure Standard LB idle timeout is 240s, so a quiet watch (no events) is silently dropped (no FIN/RST) at 240s — before the server's 290s close.
  • kube-rs 3.1.0 then polls the dead stream with a bare stream.next().await (no client-side idle deadline) → hangs forever → reflector store freezes.

Fix

A shared controller/src/watch_config.rs::bounded() sets the watch timeout to 200s (< the 240s LB idle; kube-rs rejects ≥295s), applied to every primary and secondary (.owns/.watches) watch across all 11 CRD reconcilers so it can't diverge again. The server now closes each watch at 200s — that close is traffic that prevents the connection from ever sitting idle for 240s, and the watcher re-List/re-watches (refreshing the store) within a bounded window. Overridable via KARS_WATCH_TIMEOUT_SECS (clamped 30..=600).

This is the version-independent, low-risk prevention fix on the current kube-rs 3.1.0.

Researched alternatives (documented in the module)

  • kube-rs 4.0 (released 2026-06-16, PR #1945) adds a client-side next_with_idle_timeout backstop that caps the freeze at ~295s instead of forever — but still ships no HTTP/2 keepalive ping, so this timeout fix remains valuable there too.
  • The architecturally superior, client-go-parity fix is HTTP/2 keepalive ping on the kube client (~45s dead-connection detection); it needs a custom hyper client stack + deps and is tracked as a defense-in-depth follow-up.

Verification

  • cargo clippy --all-targets -D warnings clean, cargo fmt clean.
  • 849 controller tests pass + 3 new watch_config unit tests (invariant: default < 240s; bounded() sets the timeout; env override clamped).
  • All 15 watcher::Config::default() call sites across 11 reconcilers converted; 3 now-dead imports removed.

Note

This is a controller fix — running clusters pick it up via the new kars upgrade (or kubectl rollout restart). Does not affect first-time deploys.

…on AKS (#451)

Fixes #451 — on AKS, CR spec edits (InferencePolicy/ToolPolicy/EgressApproval/
…) intermittently stopped reaching the compiled ConfigMap until a controller
restart.

Root cause: every reconciler used `watcher::Config::default()`. That is NOT
"no timeout" — kube-rs sends `timeoutSeconds=290` by default. The problem is the
value: the Azure Standard LB idle timeout is 240s, so a quiet watch is silently
dropped (no FIN/RST) at 240s, BEFORE the server's 290s close. kube-rs 3.1.0 then
polls the dead stream with a bare `stream.next().await` (no client-side idle
deadline) and hangs forever — the reflector store freezes while the 15s requeue
keeps reconciling stale cached objects.

Fix: a shared `watch_config::bounded()` that sets the watch timeout to 200s
(< the 240s LB idle; kube-rs also rejects >=295s), applied to every primary and
secondary (.owns/.watches) watch across all 11 CRD reconcilers so it can't
diverge again. The server now closes each watch at 200s — that close is traffic
that prevents the connection from ever sitting idle for 240s, and the watcher
re-List/re-watches (refreshing the store) within a bounded window. Overridable
via KARS_WATCH_TIMEOUT_SECS (clamped 30..=600) for other idle timeouts.

This is the version-independent, low-risk prevention fix and works on the current
kube-rs 3.1.0. Researched alternatives (documented in the module):
- kube-rs 4.0 adds a client-side `next_with_idle_timeout` backstop (caps the
  freeze at ~295s instead of forever) but still ships NO HTTP/2 keepalive ping.
- The architecturally superior, client-go-parity fix is HTTP/2 keepalive ping on
  the kube client (~45s dead-connection detection); it needs a custom hyper
  client stack + deps and is tracked as a defense-in-depth follow-up.

Verification: clippy -D warnings clean, cargo fmt clean, 849 controller tests
pass + 3 new watch_config unit tests (invariant: default < 240s; bounded() sets
the timeout; env override clamped).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@pallakatos pallakatos merged commit a413d10 into main Jun 26, 2026
35 checks passed
@pallakatos pallakatos deleted the fix/451-controller-watch-staleness branch June 26, 2026 05:35
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.

Controller: InferencePolicy/ToolPolicy watch goes stale on AKS — CR edits don't reach the ConfigMap without a controller restart

1 participant