feat(operator): cut stuck reconciles short and retry the rollouts they interrupt - #271
Conversation
There was a problem hiding this comment.
Wiring checks out: config.Controller.ReconciliationTimeout exists in controller-runtime v0.25.1 and controller.New defaults it from mgr.GetControllerOptions(), so both controllers pick it up. Flag, chart value, template and README agree, and the 10s client timeout the docs reference is real (internal/ferrvault/client.go:90). The new test helpers do not collide with the existing ones in the package.
One blocking finding on the interaction with --stall-threshold, plus a nit. Could not compile or run the tests: no Go toolchain in this runner, so the test assertions are reviewed by reading only.
|
Blocking: agreed on every step, and it is the reason this is a stack. #272, directly on top, is the fix you describe: So #271 alone does make the failure quieter, as you say, and it should not merge without #272. I made that explicit in the body when I opened it. Nit: taken in 5b57d62. Title fixed too: |
There was a problem hiding this comment.
Follow-up on 5b57d62. The --reconcile-timeout=0 nit is fixed and resolved; that commit touches only cmd/main.go, cmd/main_test.go and the RequestTimeout constant, so the blocking finding on the silently dropped rollout is unchanged and still stands. One nit on the new validation.
Still reviewed by reading only: no Go toolchain in this runner, so nothing here was compiled or run.
|
Nit: right, and taken in 0566e67. The floor is now the longest one API call can take, computed from the retry policy rather than hard-coded: On the blocking finding still standing: it does stand for #271 read alone, and #271 is not going to land alone. The stack merges with |
There was a problem hiding this comment.
Follow-up on 8f89480 and 8fd6740, which add the rollout retry the earlier blocking finding asked for. That approach holds up: the rollout is tracked separately from the Secret write, the error is returned so the reconcile requeues, the per-workload content-hash annotation keeps a retry from restarting workloads that already took the change, lastRolloutHash is in the chart CRD so it survives pruning, and stamping the sync gauge on a rollout failure keeps a current Secret from looking stale. That thread is resolved.
One residual case of the same failure, blocking: when the pass that hangs is also the first one that would have persisted lastRolloutHash, the record of the pending rollout dies with the cancelled context and the restart is dropped for good. Details and a suggested fix on rolloutIfDue. Plus one nit on the shared annotation key.
Still reviewed by reading only: no Go toolchain in this runner, so nothing here was compiled or run.
|
Both fixed in 3cb012b. Blocking: right, and the suite hid it for a second reason besides the missing assertion. The fake client ignores an expired context on status writes, so the end-of-reconcile The fix is yours: on a resource's first due rollout, Nit: taken, with the UID rather than the name: |
There was a problem hiding this comment.
Follow-up on 3cb012b. Both open threads are fixed and resolved: the pending rollout is persisted before triggerRollouts, and the per-workload marker is now keyed by resource UID, which fits the 63-character annotation name limit where the resource name would not have.
One finding on the new code, blocking. The pending-rollout write lands after ensureTargetSecret has already overwritten the Secret annotation that rolloutDue falls back to, so a failure of that one write, by deadline or by a 409 off a stale cache, drops the rollout for good. Details and the ordering that closes it on rolloutIfDue.
Still reviewed by reading only: no Go toolchain in this runner, so nothing here was compiled or run, and the new tests are assessed from their source.
…keep stamping the sync gauge
…ach resource's rollout marker separate
3cb012b to
4ebc553
Compare
|
Fixed in 7aa218e, with your ordering rather than my write placement, which was the actual bug: the record has to be written while the Secret still corroborates it.
Both of your entry points are covered by that, the deadline landing on this line and a 409 off a stale informer cache, because neither can now destroy the only record. New test, the case you asked for: the fixture refuses every status write, so the deadline effectively lands on
|
There was a problem hiding this comment.
Follow-up on 4ebc553. The last open blocking finding is fixed and resolved: the pending rollout is recorded before ensureTargetSecret rewrites the Secret, so no single failure in the pass can leave a rotated Secret with no record that a restart is owed.
I walked the remaining orderings against the new code and did not find another hole. A cancelled triggerRollouts leaves the stale lastRolloutHash in etcd and the next pass retries; a final status write lost to the deadline after a successful restart makes the next pass call triggerRollouts again, but the per-workload content-hash.<uid> annotation already matches, so it patches nothing; a resource whose lastRolloutHash is already set skips the extra write entirely, since the stale value in etcd is the baseline the retry needs. Upgrade from a pre-PR resource still establishes the baseline without restarting when the content has not changed.
No blocking findings, no nits. Approving, though note that nothing here counts toward a required review.
Still reviewed by reading only: no Go toolchain in this runner, so nothing was compiled or run and the tests are assessed from their source.
Part of #241. Bottom of stack #275. This now carries what was #272, folded in so the reconcile timeout can never land without the rollout retry it depends on. #272's branch was a fast-forward of this one, so nothing was rewritten.
1. A reconcile can no longer hold the queue forever
On 2026-08-25 one
Getof a Deployment blocked forever, and the operator stopped reconciling everyFerrVaultSecretfor four hours. #240 removed that cause. This removes the class.--reconcile-timeout(default2m, chart valuereconcileTimeout) feeds controller-runtime'sReconciliationTimeout, which was unset, meaning no deadline at all.Getwaiting on an informer, and in controller-runtime 0.25.1 that wait iscache.WaitForCacheSync(ctx.Done(), …), so it does.RetryPolicy.LongestCall(RequestTimeout), the longest one FerrVault API call can legitimately take: three 10s attempts plus 100ms and 400ms backoff at +25% jitter, about 30.6s. That rejects0, which in controller-runtime means no timeout, and any value that would cancel healthy reveals.2. A failed rollout is retried instead of forgotten
rolloutRestartfailures were logged and then lost. The next pass saw the target Secret already holding the new content, decided nothing had changed, and never restarted anything, while pods kept the old values underReady=True. The timeout above would turn a hang into exactly that failure, which is why the two ship together.status.lastRolloutHashrecords the content the workloads were last restarted for, and a restart is due while it differs from the current content. It is declared in the CRD schema, which does not preserve unknown fields: without that the API server would silently prune it.ferrvault.com/content-hash.<resource UID>and skipped when it already carries the current one, so a retry only touches the workloads that missed. Without that, one missing target would restart every healthy one on each retry, forever. The key is per resource so twoFerrVaultSecrets restarting the same workload do not overwrite each other's marker. It uses the UID rather than the name because the name segment of an annotation key is capped at 63 characters.sync_errors_total{reason="RolloutFailed"}, and setsRolloutRestarted=Falsewith the error.Readyand the last-sync gauge still report the data sync, which did succeed.Tests
Reconcilewhose Deployment read blocks until its context ends, the shape of the incident. It returns at the deadline, asserts the read was actually reached, and fails after 5s if that read's context is swapped forcontext.Background().0, negative,5sand20sare refused and2mis accepted.LongestCallis pinned for the default policy and for a schedule shorter than the attempts.rolloutDuetable: new Secret, upgrade with and without a change, a change, a retry with the Secret already rewritten, already restarted.False; the second restarts and flips itTrue. On the old code the second pass restarts nothing, and the test caught a baseline-loss bug in my own first attempt.[api, worker]withworkermissing:apiis restarted once across the failing passes, andworkeronce when it appears. Without the per-workload skip it fails with "restarted 2 times, want 1".The reconcile fixture (
reconcile_fixture_test.go) builds a complete reconcile against a fake client; #273 reuses it.go test ./...,go vet ./...andgofmtpass.helm lintandgolangci-lintrun in CI.