Uh oh!
There was an error while loading. Please reload this page.
fix(image-refresh): a skip that never ends is a finding, not an exit 0 (backend#1964) - #718
Conversation
…0 (backend#1964) The settled guard skips the whole tick when the jobs-manager Deployment is not settled. Right for a rollout in flight; wrong forever for a pod that never becomes Ready -- and because the skip happens BEFORE Pass 1, requests-proxy and resource-monitor stop being reconciled too while every tick exits 0 and the CronJob stays green. Nobody looks at a green CronJob. Reachable without anything crashing, and reachable BY AN OPERATOR. jobs-manager's readiness probe is a TCP connect to 8080; jobs_manager.py catches a failed run_server_in_thread on purpose so Service Bus polling and training survive. The pod then runs forever, useful, and never Ready. run_server_in_thread -> create_app() -> ensure_ingestion_runs_table() (MySQL DDL) and load_authz_policy(authz_path) (the operator-supplied ingestion-authz.yaml ConfigMap), then _ServerThread.__init__ -> make_server(), which BINDS in the constructor. So a malformed authz file, a DB user without CREATE/ALTER on that table, or a busy 8080 each freeze image refresh for four workloads. The existing flap counter cannot cover this: it counts failed ROLLOUTS, and everything after the settled guard is unreachable when the deployment never settles. Hence a second counter with the same shape -- MAX_SKIP_TICKS, default 8 (~2h at the 15m schedule), exposed as imageRefresh.maxSkipTicks. 1st..7th unsettled tick exit 0, annotate refresh-skip-streak=N (unchanged behaviour, now counted) 8th exit 1 naming ALL FOUR frozen workloads and the likely cause, so the CronJob goes red settled again clear the annotation; auto-resumes, unlike maxRefreshAttempts which needs a human annotation unreadable exit 1 -- fail-open here would reset the streak every tick and the ceiling would never be reached DELIBERATELY NOT DONE HERE: the ticket also proposes reconciling the other three components past an unsettled jobs-manager. That is unsafe as written for pods-monitor -- it is a SIDECAR in the jobs-manager Deployment (jobs-manager-deployment.yaml:458) and the CronJob re-images both containers in one patch, so refreshing it requires rolling a Deployment that cannot go Ready: incident #545's restart storm, which this guard exists to prevent. Only requests-proxy and resource-monitor can be decoupled; that is a separate PR once the decision is confirmed on the ticket. TESTS: scripts/tests/image-refresh-skip-streak.bats renders the chart, extracts the script the pod runs, and EXECUTES it against a stubbed kubectl -- exit codes and annotation writes are the assertions, not the presence of a string in the template. A grep-the-ConfigMap test would pass against logic that can never fire. Hermetic: curl is stubbed to refuse, so the suite does not reach docker.io or spend the anonymous pull-rate limit. Mutation-proved, anchors asserted: reverting the ceiling to exit 0 fails 4 tests; fail-opening the unreadable read fails 1; dropping the other-workload names from the frozen message fails exactly the case that checks for them; removing the clear-on-settled fails 1. Two of my own assertions were vacuous first time round and are fixed in place with the reason recorded: `grep -qv` does not mean "no matching line", and a whole-output grep for requests-proxy matches the STARTUP BANNER. Adjacent, NOT changed: the flap branch (#563) also exits 0 after logging "MANUAL ATTENTION NEEDED", so it has the same green-while-frozen shape. Left alone deliberately -- it is documented behaviour and belongs in its own ticket. VERIFIED: 14/14 bats, 433/433 helm unittest, helm lint clean, bats-hygiene 18/18. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The version-bump gate refuses a chart-content change that leaves Chart.yaml at develop's version: a released chart whose contents differ from the same version number is unreproducible for anyone who already pulled it. version and appVersion move together, one patch above develop (1.9.41). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
saqlainsyed007
left a comment
There was a problem hiding this comment.
Correctness review — approving.
What it does
Adds a second, independent counter (MAX_SKIP_TICKS, default 8 ≈ 2h at the 15m schedule) to the image-refresh CronJob so an unsettled jobs-manager Deployment that will never become Ready stops being a silent exit 0. Below the ceiling it annotates refresh-skip-streak=N and skips; at the ceiling it exit 1s with a finding that names the other frozen workloads (requests-proxy, resource-monitor) and points at the reachable cause. Settling clears the annotation and auto-resumes. Wired through values.yaml → CronJob env; chart 1.9.41 → 1.9.42.
Correctness — traced, holds up
- Skip path:
SKIP_KEYread is fail-closed (if ! skips="$(get_annotation …)"→ exit 1), so an unreadable annotation can't collapse the streak to 0 and mask a permanent freeze. Non-numeric/empty normalized via thecaseguard; increment-then--geceiling check is right (stored 7 → 8 → exit 1; stays failed past the ceiling). set -einteraction: both newget_annotationcalls sit inif/&&conditions, soset -eis correctly suppressed and the function's|| return 1contract is honored rather than aborting the tick.- Clear path: deliberately non-fatal on read error (settled is already proven), only writes when there's something to clear, auto-resumes. Correct asymmetry vs. the skip path.
- Missing-deployment (#571) branch unchanged and asserted.
- Env wiring /
default 8verified by the two helm-template tests.
Tests
The suite renders the chart, extracts the real image-refresh.sh, and executes it against a stubbed kubectl/curl — exit codes and annotation writes are the assertions, not template greps. Hermetic (curl stubbed to refuse). The two previously-vacuous assertions (grep -c line counting, frozen-line-scoped grep) are genuinely fixed. All CI green (bats 14/14, helm unittest 433, shellcheck, 4 template renders) and Bugbot pass on the head commit.
Non-blocking observations (considered, not requesting changes)
- On an in-progress legitimate rollout, a transient
SKIP_KEYread failure now yields a red tick where it was previouslyexit 0. This is the intended fail-closed trade-off and self-heals next tick. - The clear-path
kubectl annotate …-is unguarded, so underset -ea transient clear failure would abort before Pass 1 for that one tick. Only reachable after a real streak, self-heals next tick, consistent with the script's fail-closed philosophy.
Meticulous change — clear commentary, executable + mutation-verified tests, correct fail-closed reasoning. LGTM.
Part 1 of tracebloc/backend#1964 (surfacing). Does not close it — see "Deliberately not here".
The problem
The settled guard skips the whole tick when the jobs-manager Deployment is not settled. That is right for a rollout in flight and wrong forever for a pod that never becomes Ready — and because the skip sits before Pass 1,
requests-proxyandresource-monitorstop being reconciled too, while every tick exits 0 and the CronJob stays green.It is reachable, and reachable by an operator
Nothing has to crash. jobs-manager's readiness probe is a TCP connect to 8080, and
jobs_manager.py:3529catches a failedrun_server_in_threadon purpose so Service Bus polling and training survive. The pod then runs forever, useful, and never Ready.run_server_in_thread→create_app()→ensure_ingestion_runs_table()(a MySQL DDL migration) andload_authz_policy(authz_path); then_ServerThread.__init__→make_server(), which binds in the constructor:ingestion-authz.yaml— an operator-supplied ConfigMapload_authz_policyingestion_runs(RFC-0003 grant narrowing)ensure_ingestion_runs_tablemake_serverSo a
helm upgradetypo silently freezes image refresh for four workloads on a customer edge.The existing flap counter cannot cover it.
MAX_REFRESH_ATTEMPTScounts failed rollouts, and everything after the settled guard is unreachable when the deployment never settles.What this does
A second counter with the same shape —
MAX_SKIP_TICKS, default 8 (~2h at the 15m schedule), exposed asimageRefresh.maxSkipTicks:exit 0, silentexit 0, annotatesrefresh-skip-streak=Nexit 0, silent, foreverexit 1, names all four frozen workloads + the likely causeexit 1— fail-open would reset the streak every tick, so the ceiling would never be reachedexit 1(#571)Auto-resume is deliberate and differs from
maxRefreshAttempts, which needs a human: a skip streak means "it was busy", and settling genuinely resolves that.Deliberately not here
The ticket's decision 1 — reconcile the other three components past an unsettled jobs-manager — is unsafe as written for pods-monitor.
pods-monitor-containeris a sidecar in the jobs-manager Deployment (jobs-manager-deployment.yaml:458) and the CronJob re-images both containers in one patch, so refreshing it requires rolling a Deployment that cannot go Ready — incident #545's restart storm, which this guard exists to prevent. Onlyrequests-proxy(own Deployment) andresource-monitor(own DaemonSet) can be decoupled. That is a separate PR once the decision is confirmed on the ticket.Tests run the script, they do not grep the template
scripts/tests/image-refresh-skip-streak.batsrenders the chart, extracts the script the pod actually runs, and executes it against a stubbedkubectl. Exit codes and annotation writes are the assertions. A "the ConfigMap contains MAX_SKIP_TICKS" test would pass against logic that can never fire — the inert-verification shape backend#1729 catalogued.Hermetic:
curlis stubbed to refuse, so the suite never reaches docker.io or spends the anonymous pull-rate limit the chart is careful about. Verified stable across repeated runs.Mutation proof
Each anchor asserted, because an inert mutation and good coverage look identical in a log:
Two of my own assertions were vacuous on the first pass and are fixed in place with the reason recorded in the file:
grep -qv PATTERN filedoes not mean "no matching line" — it returns 1 on an empty file and 0 on any file with an unrelated line. Wrong in both directions; it failed one case and passed another for the wrong reason.requests-proxymatches the startup banner, so that assertion passed while the script was exiting long before the guard.Adjacent, not changed
The flap branch (#563) also
exit 0s after logging "MANUAL ATTENTION NEEDED" — the same green-while-frozen shape. Left alone deliberately: it is documented behaviour and deserves its own ticket rather than a drive-by in this diff.Test plan
bats scripts/tests/image-refresh-skip-streak.bats— 14/14, stable over 3 runsbats scripts/tests/bats-hygiene.bats— 18/18 (new file satisfies the|| return 1gate)helm unittest client— 433/433 across 30 suiteshelm lint client -f client/ci/bm-values.yaml— cleanscripts/manifest.sha256unaffected — it covers installer scripts, not chart templates🤖 Generated with Claude Code
Note
Medium Risk
Changes fleet-wide image-refresh behavior on customer edges; mis-tuned thresholds or guard bugs could false-fail jobs or miss freezes, though scope is bounded and covered by executable BATS tests.
Overview
Image-refresh now detects when the jobs-manager Deployment stays unsettled tick after tick (e.g. Running but never Ready) and stops treating that as a silent success.
When rollout status says “not settled” but the deployment exists, the script increments
tracebloc.io/refresh-skip-streakon the jobs-manager Deployment. BelowimageRefresh.maxSkipTicks(default 8, ~2h at the 15m schedule) it stillexit 0but logsN/MAX. At the ceiling itexit 1with IMAGE REFRESH IS FROZEN, naming requests-proxy and resource-monitor (reconciliation never runs while the guard skips) and hints for a never-Ready ingestion HTTP server. Unreadable streak annotations fail the tick (no fail-open reset). Once settled, the streak annotation is cleared and refresh auto-resumes—unlike the flap counter.MAX_SKIP_TICKSis wired from values into the CronJob env. Chart version 1.9.41 → 1.9.42.scripts/tests/image-refresh-skip-streak.batsrenders the chart, runs the realimage-refresh.shagainst stubbedkubectl/curl, and asserts exit codes and annotations (plus helm template checks for the env default/override).Reviewed by Cursor Bugbot for commit 80d5481. Bugbot is set up for automated code reviews on this repo. Configure here.