Skip to content

fix(chart): pin CronJob memory requests==limits so a node OOM stops culling them (backend#2935) - #948

Merged
aptracebloc merged 3 commits into
developfrom
fix/2935-cronjob-memory-oom
Sep 1, 2026
Merged

fix(chart): pin CronJob memory requests==limits so a node OOM stops culling them (backend#2935)#948
aptracebloc merged 3 commits into
developfrom
fix/2935-cronjob-memory-oom

Conversation

@aptracebloc

@aptracebloc aptracebloc commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes tracebloc/backend#2935

What was happening

ap-workspace-auto-upgrade-* was exit-137 (SIGKILL) killed on every hourly tick on a live ap-workspace edge, and image-refresh failed in lock-step — six paired failures. image-refresh runs every 15m, so failing only when it coincides with the hourly upgrade tick is itself the tell: one shared cause, not two independent container-limit overruns.

Diagnosis (measured)

  1. Both declare limits — auto-upgrade 256Mi, image-refresh 128Mi.
  2. Neither outgrew its limit; no leak/unbounded read. Measured peaks (/usr/bin/time -l): helm ~50 MiB (repo update + search + template render), kubectl ~40–80 MiB, jq ~2 MiB — each a fraction of its limit. Both scripts read only small bounded JSON and stream through awk.
  3. Shared cause = a node-level OOM. Both were Burstable, they die together, and the telemetry reason is Error, exit code 137 (not OOMKilled) — the signature of the kernel OOM-killer under node memory pressure, not either container hitting its own cgroup limit.

The fix — make both CronJobs Guaranteed QoS

Two independent levers, each doing an honest, distinct thing (this is the shape after @saadqbal's review — the first revision pinned memory only and mis-credited it):

  1. Memory request raised to meet the limit = an honest scheduler reservation. This attacks the cause: the node is no longer overcommitted by the gap between what these pods reserved and what they use, so it's less likely to enter the pressure that triggered the kill.
  2. CPU pinned too ⇒ Guaranteed QoS ⇒ oom_score_adj = -997. This is what takes the pod off the kernel OOM-killer's shortlist under pressure. A memory pin alone does not — a Burstable pod's oom_score_adj = 1000 − 1000·memRequest/nodeCapacity is fixed at start and independent of usage, so 128Mi→256Mi moves auto-upgrade only ~992→984 (16Gi node) and image-refresh 999→998 (large node), i.e. nothing.

These two CronJobs are the only chart pods one line from Guaranteed — zero initContainers, memory already equal, cpu the sole blocker. (#642 pinned jobs-manager's memory but couldn't reach Guaranteed there — it renders an unresourced init container; here we can.)

auto-upgrade:  requests cpu 50m→500m, mem 128Mi→256Mi   (limits unchanged 500m/256Mi)  → Guaranteed
image-refresh: requests cpu 20m→200m, mem  64Mi→128Mi   (limits unchanged 200m/128Mi)  → Guaranteed

Limits never moved — only requests rose to meet them, so no too-low cap was introduced. Tradeoff (stated in values.yaml): Guaranteed reserves the full envelope for the pod's brief run and caps it there; helm/kubectl are I/O-bound so the cpu cap doesn't slow real work, and Forbid + startingDeadlineSeconds bound the Pending case if a small edge can't spare the reservation.

Guard / test

auto_upgrade_test.yaml and image_refresh_test.yaml now assert cpu AND memory request==limit on each CronJob pod — the in-manifest proxy for Guaranteed given no initContainers (client#922's pod-qos-class checker will assert the class directly once it lands). Reverting either request reddens the matching assert. Also corrected the #554 comment. Chart version+appVersion bumped to 1.9.93 (develop advanced to 1.9.92 under this branch; merged and re-bumped one patch above).

Verification

  • helm unittest ./client646 passed (incl. the strengthened guards).
  • helm lint ./client — 0 charts failed.
  • CI on the prior push was green incl. Bugbot, Fleet auto-upgrade E2E, closing-ref gate, version-bump gate.

Notes for the reviewer

  • Ceiling adequacy for the heaviest op is still an untested assumption. Measured client-side helm ops (~50 MiB), not a live helm upgrade --atomic --wait. All evidence says node-pressure, so the limit was deliberately not bumped; if a live edge ever shows a >256Mi peak (it'd surface as OOMKilled), the ceiling is the thing to raise — orthogonal to the QoS fix.
  • Fix-the-class: other Burstable-by-memory pods exist (requests-proxy, resource-monitor, the egress-*/storage-assertions check hooks); they were not observed OOMing and are out of scope here. Worth a look only if fleet-wide pressure is confirmed.

Decision gate (recorded, not folded in)

  • "Do" item 1 (why the node was under pressure) — the pressure source isn't identifiable from the failure telemetry (we know it wasn't these two containers; the prime suspect is a spawned training pod whose envelope dwarfs the control plane). Landed as a line on backend#2935 with what to capture next time; the QoS fix makes these two survivors of pressure, it doesn't remove the cause.
  • "Do" item 2 (visibility of a stalled upgrade path regardless of environment) — flagged on backend#2920, where the dev-exclusion policy lives.
  • "Do" item 3 (fleet not swept) — only ap-workspace inspected; recommended a fleet check on backend#2935.

Note

Medium Risk
Changes scheduler reservations and OOM victim preference for the security-fix auto-upgrade path on memory-tight edges; behavior is intentional but can leave CronJobs Pending if the node cannot honor Guaranteed envelopes.

Overview
Addresses backend#2935, where auto-upgrade and image-refresh CronJob pods were exit-137 killed together on a live edge—consistent with node-level OOM, not cgroup limit breaches (measured peaks stayed well under existing limits).

Resource requests for both CronJobs are raised so CPU and memory requests equal their existing limits, moving the pods to Guaranteed QoS (oom_score_adj -997) and reserving memory honestly on the node. Limits are unchanged (auto-upgrade stays 500m/256Mi; image-refresh 200m/128Mi). Tradeoff: tighter scheduler reservation on small edges; pods may go Pending rather than OOM-loop.

Helm unittest cases in auto_upgrade_test.yaml and image_refresh_test.yaml lock request==limit for CPU and memory so the QoS fix cannot regress. The #554 pending-upgrade recovery comment in auto-upgrade-cronjob.yaml is updated to mention node OOM and Guaranteed QoS. Chart version/appVersion bump to 1.9.93.

Reviewed by Cursor Bugbot for commit 638b977. Bugbot is set up for automated code reviews on this repo. Configure here.

…ulling them (backend#2935)

The ap-workspace-auto-upgrade CronJob was exit-137 (SIGKILL) killed on every
hourly tick on a live edge, and image-refresh failed in lock-step (six paired
failures) — together, which points at ONE shared cause, not two independent
container-limit overruns.

Diagnosis (measured, not assumed):
- Both CronJobs already declare memory limits: auto-upgrade 256Mi, image-refresh
  128Mi.
- Neither workload's own working set approaches its limit. helm's peak on this
  chart is ~50Mi (repo update + search + `helm template` render); kubectl's is
  ~40-80Mi (one `get deployment -o json` + `rollout status`; the curl HEADs
  return headers only; jq ~2Mi). So the limit was never the binding constraint,
  and there is no leak or unbounded read (both scripts read bounded JSON and
  stream through awk).
- What they share is QoS: both are Burstable with requests BELOW real usage
  (128Mi<256Mi, 64Mi<128Mi). A Burstable pod running above its memory request
  draws a high oom_score_adj, so under NODE memory pressure the kernel
  OOM-killer targets it first. The telemetry reason is "Error, exit code 137"
  (not OOMKilled), consistent with a node-level kill rather than a container
  hitting its own cgroup limit. auto-upgrade at :23 and image-refresh at :22
  overlap, so a single pressure event culls both.

Fix — the repo's established remedy for exactly this exit-137 signature:
pin requests.memory == limits.memory on both CronJobs (auto-upgrade 256Mi,
image-refresh 128Mi). This reserves the memory and lowers oom_score_adj (the
pods stay Burstable) so the node OOM-killer no longer prefers them — identical
to jobs-manager/pods-monitor after their exit-137 mass-restart OOM (#642,
backend#1144), the GPU device plugins (#919), and wait-for-mysql (backend#2913).
Limits are UNCHANGED — this is not a "move the cliff" bump; the measured
footprints show >3x headroom already. CPU stays burstable (a share weight,
throttled not killed — backend#2418).

Guard: auto_upgrade_test.yaml and image_refresh_test.yaml now assert
requests.memory == limits.memory on each CronJob pod, so the gap that made them
easy victims cannot silently reopen. Chart version bumped 1.9.91 -> 1.9.92.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aptracebloc aptracebloc self-assigned this Sep 1, 2026

@saadqbal saadqbal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good diagnosis — the paired-failure timing argument (6 image-refresh failures out of ~24 runs,
exactly the ones coinciding with the hourly tick) and the Error vs OOMKilled distinction are
both right, and I agree this is a kernel node-OOM rather than either container overrunning its
own cgroup. The peak-RSS measurements are the right instinct too.

But the remedy doesn't reach that mechanism, and the comment says it does. oom_score_adj for a
Burstable pod is 1000 - 1000*memRequest/nodeCapacity — a function of the request against node
capacity
, set once at container start. It does not depend on whether the pod is running above
its request, which is what the values.yaml note asserts. So 128Mi→256Mi moves auto-upgrade
993→985 on a 16Gi node, in a 3..999 band; image-refresh 64Mi→128Mi on a 64Gi node moves 999→999,
i.e. nothing. Guaranteed is −997. "A node under pressure no longer prefers it" isn't what this
buys.

And these two are the only pods in the chart one line from Guaranteed — I checked: zero
initContainers on either CronJob, memory now equal, cpu the sole blocker
(helm:cpu(req=50m,lim=500m)). The #642 precedent doesn't transfer, because jobs-manager
renders an unresourced init container and can't reach Guaranteed however you set cpu; these can.
"Share weight, throttled not killed" is true in isolation, but cpu equality is also the QoS
precondition, and that's the part that matters here.

So: either pin cpu and take Guaranteed, stating the reservation-vs-throttle tradeoff, or keep the
memory pin and rewrite the claim as what it actually delivers — an honest scheduler reservation
that reduces node overcommit, which is a real structural fix for #2935's cause and currently
framed as a side-note. I'd take the first for auto-upgrade; it's the security-fix delivery path.

Also nothing establishes why the node was under pressure. #2935's "Do" item 1 is half-answered
— we know it wasn't these two containers, we don't know what it was — and I'd rather that line
landed on the issue before it closes.

Rest is clean and the safety work is solid. No new top-level values key, so no nil-guard needed.
Limits never moved and requests rose to meet them, so no too-low cap was introduced. Guards are
mutation-proof both ways — reverting the request, then separately raising only the limit, each
reddens the right assert. And on the coupling worry: #922's full 19-test suite and all five
goldens pass verbatim against this branch, both CronJobs still Burstable, so no golden moves and
the two can land in either order. Forbid plus startingDeadlineSeconds already bound the
Pending case from your operator note.

Nit: the tests assert two literal values rather than the relation or the class. With #922's
checker landing in the same window the class is derivable in-repo — and "asserted the values
believed to imply the class" is the exact phrase in its docstring.

aptracebloc and others added 2 commits September 1, 2026 10:48
…ned (backend#2935)

Addresses @saadqbal's review on client#948. The memory-only pin was the wrong
mechanism for the claim it carried: a Burstable pod's oom_score_adj is
1000 - 1000*memRequest/nodeCapacity, fixed at container start and independent of
usage, so raising the request 128Mi->256Mi moves auto-upgrade ~992->984 on a 16Gi
node and image-refresh 999->998 on a large node — it does NOT "stop the OOM-killer
preferring the pod". Only Guaranteed QoS (oom_score_adj -997) does that.

These two CronJobs are the only chart pods one line from Guaranteed: zero
initContainers, memory already equal, cpu the sole blocker. (#642 pinned
jobs-manager's memory but could not reach Guaranteed there — it renders an
unresourced init container; here we can.) So pin cpu too:
- auto-upgrade:  cpu request 50m->500m (== limit); memory already 256Mi==256Mi.
- image-refresh: cpu request 20m->200m (== limit); memory already 128Mi==128Mi.
Limits never moved — only requests rose to meet them.

The two levers now do two honest things: the memory RESERVATION reduces the node
overcommit that let pressure build (the structural half — #2935's cause), and
Guaranteed's -997 keeps the pod off the OOM-killer's shortlist under pressure (the
mechanism). Reservation-vs-throttle tradeoff stated in values.yaml; helm/kubectl
are I/O-bound so the cpu cap does not slow real work; Forbid + startingDeadlineSeconds
bound the Pending case.

Guards now assert cpu AND memory request==limit on both pods (the in-manifest proxy
for Guaranteed given no initContainers; client#922's checker will assert the class
directly). Comments corrected to credit each lever with what it actually delivers.
Chart 1.9.92 -> 1.9.93 (develop advanced to 1.9.92 under this branch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aptracebloc

Copy link
Copy Markdown
Contributor Author

Thanks @saadqbal — this was exactly right, and I took the first option. Pushed 036ecf1 (+ a develop merge, 638b977).

oom_score_adj mechanism / the claim. You're correct and I verified the arithmetic: 1000 − 1000·memRequest/nodeCapacity, fixed at start, usage-independent → auto-upgrade 992→984 on 16Gi, image-refresh 999→998 on a large node. The memory pin does not buy "the node no longer prefers it." Fixed both the mechanism and the framing:

  • Went Guaranteed on both (pinned cpu req==limit: auto-upgrade 50m→500m, image-refresh 20m→200m; memory was already equal). oom_score_adj = -997 is now what actually moves them off the shortlist.
  • Rewrote the values.yaml notes to credit each lever with what it delivers: memory request = honest reservation that reduces node overcommit (the structural half — #2935's cause); Guaranteed's −997 = the victim-preference change (the mechanism). The old "running above its request draws a high oom_score_adj" line is gone, with a pointer to this review.

#642 doesn't transfer. Agreed — noted in the comment that jobs-manager renders an unresourced init container and can't reach Guaranteed however cpu is set, whereas these two have zero initContainers and can, which is why they get the stronger fix.

Reservation-vs-throttle tradeoff is now stated in values.yaml: Guaranteed reserves the full envelope for the brief run and caps it there; helm/kubectl are I/O-bound so the cap doesn't slow real work; limits never moved (only requests rose to meet them); Forbid + startingDeadlineSeconds bound the Pending case.

"Do" item 1 (why the node was under pressure). Landed on backend#2935: we know it wasn't these two containers, we can't identify the source from the failure telemetry (no node memory in hand), prime suspect is a spawned training pod whose envelope dwarfs the control plane — with the describe node / per-pod-RSS to capture next time. Called out that the QoS fix makes these two survivors of pressure but doesn't remove the cause. Offered to split the source hunt into a follow-up.

Tests nit. Guards now assert cpu AND memory request==limit on both pods (the in-manifest proxy for Guaranteed given no initContainers), not just memory literals. Left a note that client#922's pod-qos-class checker asserts the class directly once it lands — happy to switch these to call it in a follow-up so we're not asserting the values-that-imply-the-class.

Re-requesting your review.

@aptracebloc
aptracebloc requested a review from saadqbal September 1, 2026 08:56
@aptracebloc

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 638b977. Configure here.

@LukasWodka LukasWodka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read this at 638b977d. The diagnosis is right and the correction Asad asked for landed properly — not approving only because his CHANGES_REQUESTED is still standing (recorded at 563037df; GitHub dismisses stale approvals on push, not change-requests, so it needs him to clear it). Everything below is so the re-read is short.

The oom_score_adj arithmetic now in the comment is correct, and it was worth correcting. Burstable is 1000 - 1000 * memRequest / nodeCapacity, fixed at pod start and independent of actual usage, so on a 16Gi node 128Mi → 256Mi moves ~992 → ~984 — noise against a Guaranteed pod's -997. The earlier framing credited the memory pin with the victim-preference change; it doesn't do that, the CPU pin does, by moving the pod into Guaranteed. Two levers, two different mechanisms, and the current comment separates them cleanly: the memory request is an honest reservation that reduces node overcommit (attacking the cause), the CPU pin is what moves it off the kernel's shortlist (attacking the symptom). Worth having both, and worth having said which does which.

I checked the tradeoff you flag rather than taking the reassurance. Reserving 500m/256Mi for a brief hourly run can make the pod unschedulable on a small edge, and with concurrencyPolicy: Forbid a single stuck-Pending Job would block every later tick — which for the security-fix delivery path would be a quieter failure than the OOM loop it replaces. It's genuinely bounded: activeDeadlineSeconds is set on both CronJobs (max(900, timeout+300) on auto-upgrade, default 3600 on image-refresh), and a Job's deadline runs from startTime regardless of whether a pod ever scheduled, so a Pending Job self-terminates and releases Forbid. The chart already says as much a few lines up. So the tradeoff is real, acknowledged, and has a working brake — no objection from me.

One gap in the guard, smaller than the one you already named. The comments say the assertion is a proxy for Guaranteed because there are no initContainers — true, I checked both templates. But the QoS class depends on every container in the pod, and the tests assert containers[0] only. Today each CronJob renders exactly one container (helm, refresh), so the proxy holds; add a sidecar tomorrow and the assertions stay green while the class silently drops to Burstable — the same shape of hole as the initContainer one, via a different door. Not worth a commit here given client#922 lands the direct pod-qos-class checker, but the comment naming only initContainers slightly overstates what the proxy covers.

Two things I verified and am happy with: Helm unit tests ran (16s, pass) so the new assertions actually executed rather than being skipped, and both new tests fail if either request is reverted — each resource has its own equal, so the mutation is per-lever rather than one line covering both.

Gate otherwise: 24 pass, 0 failing, 0 pending, MERGEABLE, zero unresolved threads. Ready from my side the moment Asad clears his review.

@saadqbal saadqbal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right shape now — thanks for taking the cpu pin rather than just softening the prose.
I checked the class rather than the values: #922's pod-qos-class.py, which implements
ComputePodQOS including init containers, derives Guaranteed for both t-auto-upgrade and
t-image-refresh off this branch's render on all five profiles. Single container, zero
initContainers each, so the class is real, and the comment now credits the right lever to the
right mechanism. Clearing my review.

Two things before you merge, neither of them code.

Closes tracebloc/backend#2935 no longer matches the ticket. Lukas retitled it at 09:35 — after
your last push — and the headline is now 431x exit 1 against 46x exit 137. This PR fixes the
46. I checked whether the exit-1s might be downstream of the OOM kills and they aren't: the
pending-upgrade wedge path deliberately exit 0s (#2877), so the 431 are the LATEST/CURRENT
resolution failures under set -eu, and nothing here touches them. Make it Part of and give the
exit-1 half its own ticket. Cross-repo keywords don't auto-close here — every prior one was closed
by hand — which is exactly how someone reads "Closes" and closes it anyway, and that edge is still
missing security fixes.

Merge order against #922, and this reverses what I told you last pass. Then, the memory-only pin
moved no golden. The cpu pin does: all five pod-qos-expect.*.txt files move two rows each —
class t-auto-upgrade and class t-image-refresh, Burstable -> Guaranteed, ten rows total. On the
merged tree #922's suite goes 19/19 to 15/19 (tests 1, 2, 3, 18). The init rows don't move, and
qos-reachability-expect.txt stays 20/20 both ways because it already recorded both as reachable.
Land #922 first and let this PR carry the ten rows, or land this first and expect #922 to. Chart.yaml
collides either way — #922 is at 1.9.94 and you're at 1.9.93, so the second one re-bumps.

Nit, and it argues for #922 landing first: the comment says the proxy holds because there are no
initContainers, but the tests read containers[0] only. That's Lukas's point and it's the same hole
through a different door — add a sidecar and these stay green while the class drops. #922's checker
closes it properly.

Nit: the arithmetic is off by one, in the direction that understates your own argument. kubelet
integer-divides on bytes, so 16Gi is 993->985 rather than 992->984, and image-refresh on a 64Gi node
is 999->999 — the request rounds to zero and the 1000 is clamped. Literally no movement, which is
the stronger version of the point you're making.

Good call landing the "Do item 1" comment on #2935 bounding the node-pressure cause as not
identified
rather than guessing — what we know, what we don't, the unconfirmed suspect, and what to
capture next time. That's the part that stops this being re-diagnosed from scratch.

@aptracebloc
aptracebloc merged commit 526f4ad into develop Sep 1, 2026
45 of 46 checks passed
@aptracebloc
aptracebloc deleted the fix/2935-cronjob-memory-oom branch September 1, 2026 10:21
LukasWodka added a commit that referenced this pull request Sep 1, 2026
…#2872)

The four QoS failures were real and were NOT visible on the branch head. CI
evaluates the pull_request MERGE of head into base, and #948 (526f4ad) landed on
develop after this branch last merged it: pinning CronJob memory to
requests == limits moves t-auto-upgrade and t-image-refresh from Burstable to
Guaranteed. On the head alone every case passes, which is why it first read as an
environment difference; reproduced by merging develop locally.

Measured under CI pinned helm v3.15.4 on the merge -- both CronJobs Guaranteed on
every profile -- and all five expectation files updated. Nothing in the chart
prose called the CronJobs Burstable, so no comment needed correcting.

Rows re-derived from measurement rather than restored from a pre-revert copy:
doing the latter would have reintroduced the TEST that 2d25d1a reverted without
its code. That revert and b0e4500 diagnostic both stand untouched.

Separately worth recording: local helm here is v4.1.1 while CI pins v3.15.4, so
chart renders verified locally were not on the CI configuration. Everything above
ran under 3.15.4.

drift 43/43, helm unittest 660/660, full bats 1647/1647 -- on the merge, v3.15.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LukasWodka added a commit that referenced this pull request Sep 1, 2026
…ss for real (backend#2872) (#922)

* docs(qos): correct the false Guaranteed-QoS claims and assert the class for real (backend#2872)

Six claims in this repo said a workload had Guaranteed QoS. None did, and two
files contradicted themselves internally -- install-client-helm.sh:38 said
"requests == limits (Guaranteed QoS)" while :324 said "Guaranteed QoS is lost by
design", and install-k8s.ps1 carried the same pair. The training-envelope claims
went stale when backend#2418 made CPU a request-only share weight; nobody
noticed because no test tier could assert a QoS class.

Corrected: values.yaml, values.schema.json (which SHIPS to users),
jobs-manager-deployment.yaml, mysql-deployment.yaml, install-client-helm.sh,
install-k8s.ps1.

Deleted the false history at values.yaml:964, which called jobs-manager
"BestEffort QoS in older releases". It was Burstable from day one: before #66
introduced the resources block the template hardcoded requests cpu 100m /
memory 256Mi and limits cpu 500m / memory 512Mi
(git show 2d9d013^:client/templates/jobs-manager-deployment.yaml). Requests AND
limits set, so never BestEffort. The pod was under-resourced, which is the real
cause; the class was never the difference.

Amended the ACCURATE comments too. They attributed Burstable to cpu req != lim
alone, which is true but incomplete and would send the next fix attempt down a
dead end: ComputePodQOS requires requests == limits in every container INCLUDING
init containers, and jobs-manager renders an unresourced init-writable-data
whenever hostPath.enabled=true while mysql renders an unresourced
mysql-format-guard UNCONDITIONALLY. Measured on the rendered chart: equalising
cpu buys Guaranteed for jobs-manager only on a CSI cluster, and for mysql
nowhere.

New scripts/tests/pod-qos-class.{py,bats} DERIVES each pod class with the
kubelet rule instead of asserting the values believed to imply it -- the defect
shape that let all of this survive. Asserted per hostPath mode, because the mode
changes the answer. Six mutations proven to redden; two of them (an ignored
memory dimension, a workload silently becoming BestEffort) exposed real gaps in
the first version of the suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(qos-check): only cpu and memory count toward the class (backend#2871)

The first version of pod-qos-class.py treated ANY resource key as
qos-relevant. ComputePodQOS skips everything isSupportedQoSComputeResource
rejects, so a container whose only requests are nvidia.com/gpu and
ephemeral-storage -- exactly what client-runtime._get_gpu_resources produces
for every GPU training pod -- has EMPTY qos-relevant maps and the pod is
BestEffort, not Burstable.

So the checker would have reported those pods as Burstable and quietly agreed
they were fine: the same defect shape it exists to catch. Found while verifying
backend#2871 against the real code rather than trusting the ticket.

Two tests added (gpu-only -> BestEffort; extended resources alongside equal
cpu/memory -> still Guaranteed, so the rule is not "ignore unknown keys"), and
the mutation back to the old behaviour reddens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(chart): bump to 1.9.88 — develop reached 1.9.87 while this PR was open

* docs(qos): the class follows DERIVE_JOB_ENVELOPE, not the template (backend#2872)

Review on rfcs#63 (@saadqbal) caught this sweep swapping one wrong QoS claim for
another -- the one thing it cannot afford to do. Three of my own claims here had
the same defect: they read as though the derive path's Burstable envelope were
the DEFAULT.

It is not. DERIVE_JOB_ENVELOPE is off by default (client-runtime
jobs_manager.py:2664), and the fallback assigns the literal cpu=1,memory=2Gi to
requests AND limits, so those pods are GUARANTEED -- client-runtime says so
outright at :2412: "the literal (and a symmetric env-override) has request ==
limit, so those pods are Guaranteed ... The derive path is deliberately Burstable
on CPU instead." There is even a test named
test_derivation_disabled_by_default_uses_literal.

So values.yaml, values.schema.json (which ships) and the template comment now
split by the flag and state BOTH classes, rather than naming one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(chart): bump to 1.9.90 — develop reached 1.9.89 while this PR was open

* test(qos): derive the expected class set from the render, not a hand-list (backend#2872)

Bugbot, and it is right about the guard I built to close this exact shape. The
expected table restated SIX workload names in the bats file. The chart renders TEN:
t-auto-upgrade, t-image-refresh, t-egress-reachability-check and
t-storage-assertions-check were classified by the checker and then ignored by the
assertion, so a silent Burstable<->Guaranteed change on any of them stayed green.
And the init-container check tested that two names APPEARED -- a membership test
where a set comparison was needed -- so init-mysql-data could vanish with nothing
reddening.

A restated list inside a guard against restated claims. CLAUDE.md rule 1, in the
file that cites it.

Now `pod-qos-class.py --expect <file>` compares the render against a declared
expectation by SET EQUALITY IN BOTH DIRECTIONS: a workload the chart starts
rendering fails until someone classifies it, and a row naming a workload the chart
no longer renders fails rather than being satisfied by nothing. The unresourced-init
set is asserted exactly, because that set is what decides whether Guaranteed is
reachable at all.

Three new tests prove the check can fail: a dropped row, a stale row, and a
vanished init container each redden. Five mutations proven, including a REAL chart
mutation -- stripping resources from image-refresh-cronjob.yaml, one of the four
workloads that was previously invisible -- which now reddens both mode assertions.

Verified: bats 53/53, helm unittest 641/641, drift 37/37, version guard ✓.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(qos): render the GPU device plugins — the chart's only Guaranteed pods (backend#2872)

Bugbot, and it is the sharpest gap yet: `_render` never enabled
gpu.devicePlugin, so neither nvidia-device-plugin-daemonset nor
amdgpu-device-plugin-daemonset was ever classified. Measured: both are
GUARANTEED -- which is exactly what client#919 bought. Stripping their
resources would return them to BestEffort, leave every GPU training pod
Pending, and this guard would have stayed green.

So the one place Guaranteed actually exists in this chart was the one place the
QoS suite could not see.

_render now takes extra --set pairs, and the suite asserts a class table PER
VENDOR -- the chart renders one plugin keyed on gpu.devicePlugin.vendor, so
asserting only nvidia would leave the amd template unclassified, the same
partial-coverage mistake one level down.

Side benefit worth naming: the checker's Guaranteed path is now exercised
against REAL chart output rather than only synthetic manifests. A classifier
that could never produce "Guaranteed" from the chart would previously have
passed every assertion in this file.

Proven with a real chart mutation, anchor asserted: renaming both `resources:`
blocks in gpu-device-plugin.yaml reddens the two new tests. My first attempt
used the wrong indentation, matched 0 lines, and reported "gap NOT closed" --
which is why the anchor assertion is there.

Verified: bats 55/55, helm unittest 641/641, drift 37/37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(qos): the "only route" claim was false, and the preflight skipped .py guards (backend#2872)

Review on client#922, all verified on this head rather than taken on trust.

- "Pod-level resources are the only route" was the same false QoS claim in the PR
  whose job is deleting false QoS claims. Resourcing every init container reaches
  Guaranteed too: verified by patching the render so every container including the
  init containers carries requests == limits, at which point pod-qos-class.py
  returns Guaranteed for BOTH t-jobs-manager and mysql-client. Rejected on cost
  (the reservation is held for the pod's whole life), not capability.
- KEP-2837 is beta in 1.34, not 1.36. Checked rather than trusted, as asked; the
  "measured on a real 1.36 cluster" mentions are about our own pin and stand.
- pyyaml-preflight enumerated .sh and .bats only, so its "the denominator is the
  tree" header was false and pod-qos-class.py was invisible to it. Now 23 guards,
  with .py treated as whole-file python -- extract_python finds only embedded
  blocks, so without that a python guard would fail closed while correctly wrapped.
  Mutation-proved: a bare `import yaml` reddens test 1 naming pod-qos-class.py.
- Deleted _class_of: dead after the --expect rewrite, and its unanchored `$1 ~ w`
  would have matched a sibling workload and asserted a different pod's class.
- Six claims, not ten, in both headers. CLAUDE.md rule numbers replaced with the
  rules themselves -- a reader in this repo cannot resolve a number that lives in
  a workspace file.
- _norm's zero-quantity exception stated: requests == limits == 0 derives
  Guaranteed here and is BestEffort on a cluster.

helm unittest 641/641, drift 38/38, check-facts, qos 17/17, preflight 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(qos): classify the lockdown Job, and fail closed on any unreached template (backend#2872)

Bugbot Medium on client#922, verified before fixing: `egress-enforcement-check`
renders only with `networkPolicy.training.enabled=true`,
`allowExternalHttps=false` AND a probe host set -- three values no mode in this
suite set, so the Job was never classified. Stripping its resources demoted it to
BestEffort with the suite green.

TWO FIXES, because one more mode closes the instance and not the class.

- A lockdown render mode plus `pod-qos-expect.lockdown.txt`, compared by set
  equality in both directions like every other mode. Mutation: removing the Job's
  `resources` block reddens test 18.

- A CROSS-MODE COVERAGE ASSERTION, both sides derived. The chart side greps the
  templates for a pod-bearing `kind`; the reached side is the union of a new
  `--sources` mode over all five renders. It reads helm's `# Source:` comment from
  the raw text, because `yaml.safe_load_all` discards it and a workload NAME cannot
  say which file produced it -- a template that renders nothing contributes no name
  to notice the absence of. Empty on either side fails closed. Mutation: a new
  conditionally-rendered Deployment no mode reaches reddens test 19 by name.

The coverage guard earned itself immediately: it caught my own first draft passing
`gpu.enabled` / `gpu.vendor` instead of `gpu.devicePlugin.*`, so the two GPU modes
were rendering no device plugin at all. That is exactly the failure it exists to
report, found on the way in.

Merged develop; the manifest conflict is a hash, so the only correct resolution is
the recomputed value, not either side.

helm unittest 641/641, drift 38/38, qos 19/19, check-facts 14/14, preflight 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(qos): the coverage guard held its own copy of the kind list (backend#2872)

Bugbot on client#922, and it is my own restatement one commit old: the coverage
assertion grepped for a hand-written
`Deployment|StatefulSet|DaemonSet|Job|CronJob` while `POD_KINDS` also carries
`Pod`. A raw Pod template was therefore absent from the denominator, so if no mode
rendered it, coverage stayed green and `--expect` never saw it -- a BestEffort
demotion on that pod would not fail anything.

Fixed by removing the copy, not by adding `Pod` to it: a new `--kinds` mode prints
POD_KINDS and the bats side builds its grep pattern from that, so the two cannot
disagree again. Empty output fails closed -- an empty alternation would make the
grep match every template or none, and either way the set comparison stops meaning
anything.

Mutation, Bugbot's exact scenario: a `kind: Pod` template gated behind a value no
mode sets. With the derived list, test 19 fails naming `zz-raw-pod.yaml`; with the
old hand-written list re-pasted in its place, the same template is invisible and
test 19 passes. Both measured.

helm unittest 641/641, drift 38/38, qos 19/19, preflight 3/3, shellcheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(qos): the class follows the flag AND whether the pod asks for a GPU (backend#2872)

Asad, review of #922. The correction overshot in the three places that ship:
values.schema.json, values.yaml and the jobs-manager comment all said the class
follows the flag, full stop. On a GPU cluster it does not -- client-runtime
resolves a GPU envelope to nvidia.com/gpu and ephemeral-storage only, neither is
QoS-relevant, so the container sets no cpu/memory at all and the pod is
BestEffort under EITHER setting.

That matters because the schema description ships to operators, and the
BestEffort/oom_score_adj argument used to justify Guaranteed elsewhere is exactly
what a GPU operator would conclude does not apply to them -- then flip
DERIVE_JOB_ENVELOPE on to escape Burstable and change nothing on their cluster.
All three now carry the same sentence: cpu-only + off -> Guaranteed, cpu-only +
on -> Burstable, GPU -> BestEffort either way. pod-qos-class.py already encoded
the carve-out (345ab1d in this PR); the prose asserted the opposite of the
chart' own checker.

Dropped the verbatim client-runtime quote that propped up the Guaranteed claim.
A cross-repo prose quote is a dependency nothing checks -- client-runtime#457
exists to delete one in the other direction -- and it pinned the very sentence
being corrected here. Cite the behaviour and where it lives, do not copy it.

Declared the checker' third divergence: a limits-only container reads Burstable
here and is Guaranteed on a cluster, because the API server defaults requests
from limits before the class is computed and this reads the rendered manifest.
Safe direction, no chart pod does it -- written down because the other two are,
and an undeclared divergence is paid for by whoever meets a red CI on correct
code with nothing to read.

helm unittest 641/641 (the body said 631 -- fixed there too). bats
pod-qos-class: 19/19. make drift: all 38 guards green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(chart): bump to 1.9.94 (backend#2872)

develop reached 1.9.92 when #942 merged, so this branch no longer sat above
it and `chart content => Chart.yaml version bump` failed. The gate compares
against the develop tip (BASE_SHA), not the fork point, so a bump goes stale
whenever develop moves under a long-lived branch.

Caught by re-running the check rather than reading it: the PR still showed a
green pass from before #942 landed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(qos): correct the claims #942 falsified, and assert REACHABILITY (backend#2872)

Three things, all from @saadqbal review of client#922.

1. #942 FALSIFIED TWO CLAIMS THIS PR ADDS. Its `wait-for-mysql` is
unconditional and unequal on BOTH dimensions (cpu 10m/100m, memory 16Mi/64Mi)
with no values key, so `values.yaml` and `jobs-manager-deployment.yaml` were
both wrong to say equalising cpu buys Guaranteed for jobs-manager on a CSI
cluster. Measured on the merged tree with hostPath off and every resources.*
knob equalised:

  t-jobs-manager  Burstable  wait-for-mysql:cpu(req=10m,lim=100m);
                             wait-for-mysql:memory(req=16Mi,lim=64Mi)

Guaranteed is now unreachable through values on EVERY cluster. Both comments
corrected.

2. AND THE GUARD STAYED GREEN THROUGH IT -- this PR subject one turn deeper.
The goldens pin the CLASS and the unresourced-init SET; `wait-for-mysql` is
resourced, merely unequal, and jobs-manager was already Burstable, so nothing
moved. `scripts/tests/qos-reachability.sh` asserts reachability: it equalises
every resources.* knob derived from values.schema.json, classifies through
pod-qos-class.py (no second copy of ComputePodQOS), and compares per-pod
verdicts to a golden by set equality both ways.

Proved rather than argued -- adding an unconditional unequal init container to
an ALREADY-Burstable pod (the exact #942 shape) leaves all 19 existing QoS
tests green and reddens this guard, naming the container. The golden records
blocking container NAMES, not quantities, so a resource bump does not churn it.

3. BOTH INSTALLER HEADERS called the training pod BURSTABLE flat. On a GPU edge
it is BestEffort: the GPU path requests only nvidia.com/gpu / amd.com/gpu plus
ephemeral-storage, and client-runtime `_get_gpu_resources` never reads
RESOURCE_REQUESTS / RESOURCE_LIMITS there. backend#2871 raised both GPU
BestEffort workloads; client#919 fixed the device-plugin half and the issue was
CLOSED with the training half unfixed, so the record is written into the two
files rather than left as a reference.

Chart 1.9.94. drift 43/43, helm unittest 657/657, QoS 19/19, Pester 887/887.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(qos): walk EVERY resources node, not just the top-level one (backend#2872)

autoUpgrade, imageRefresh and egressProxy each expose requests and limits
under their own parent, so they were never equalised and their pods read
blocked while an operator could already reach Guaranteed through values.
The guard was agreeing with its own incomplete domain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(qos): probe the free-form resources node instead of recording it blocked (backend#2872)

Builds on 691841e rather than replacing it -- the recursive walk and the
fatal/informational note split are that commit, and they are right.

One verdict was still wrong: `telemetryCollector.resources` is
`{"type": "object"}` with no declared keys, so it fell into #NOTAPAIR and
t-telemetry-collector was recorded `blocked`. But the schema does not forbid
keys either, and the DaemonSet renders the node with `toYaml $tc.resources` -- a
wholesale passthrough -- so a requests/limits pair set there reaches the pod.

Measured, with ONLY that key equalised:

  t-telemetry-collector  Guaranteed  every container has requests == limits
  otel-collector {requests: {cpu: 1000m, memory: 1Gi}, limits: {same}}

So `blocked` was a verdict reached by never having looked -- the one thing this
guard exists to refuse. Free-form nodes are now PROBED with the canonical pair
and the run reports that the verdict came from a probe rather than a
declaration. `gpu.devicePlugin.*` stays #NOTAPAIR: its flat one-pair shape is
applied to both sides by construction (client#919 makes the split form
unexpressible), so there is genuinely nothing to equalise.

Golden: two rows corrected, with the reason recorded beside them.

Mutation-proved: disabling the probe branch returns both rows to `blocked` and
reddens the guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(qos): the pod-level branch needs the cpu/memory carve-out too (backend#2872)

KEP-2837 pod-level resources short-circuit above the container walk, so an
envelope of extended resources only returned Burstable where the kubelet
says BestEffort. The container path already carried this guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Revert "fix(qos): the pod-level branch needs the cpu/memory carve-out too (backend#2872)"

This reverts commit 51681eb.

* test(qos): report the checker output when an expectation fails (backend#2872)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(qos): record that #948 moved both CronJobs to Guaranteed (backend#2872)

The four QoS failures were real and were NOT visible on the branch head. CI
evaluates the pull_request MERGE of head into base, and #948 (526f4ad) landed on
develop after this branch last merged it: pinning CronJob memory to
requests == limits moves t-auto-upgrade and t-image-refresh from Burstable to
Guaranteed. On the head alone every case passes, which is why it first read as an
environment difference; reproduced by merging develop locally.

Measured under CI pinned helm v3.15.4 on the merge -- both CronJobs Guaranteed on
every profile -- and all five expectation files updated. Nothing in the chart
prose called the CronJobs Burstable, so no comment needed correcting.

Rows re-derived from measurement rather than restored from a pre-revert copy:
doing the latter would have reintroduced the TEST that 2d25d1a reverted without
its code. That revert and b0e4500 diagnostic both stand untouched.

Separately worth recording: local helm here is v4.1.1 while CI pins v3.15.4, so
chart renders verified locally were not on the CI configuration. Everything above
ran under 3.15.4.

drift 43/43, helm unittest 660/660, full bats 1647/1647 -- on the merge, v3.15.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(qos): harden the coverage loop renders, and say what the finding did NOT reproduce (backend#2872)

Bugbot Medium: the coverage loop called `_render` in five case arms with no
`|| return 1` -- the only `_render` calls in the file without it -- so a failed
render fell through and whatever it left behind was measured for coverage.

THE MECHANISM AS DESCRIBED DOES NOT REPRODUCE, and that belongs on the record
rather than being implied away. The finding said `helm template` streams, so a
failed render can still emit earlier pod documents. Measured on the pinned
v3.15.4 AND on v4.1.1, across three failure modes -- a late template `fail`, a
values-schema violation, and invalid YAML in the rendered output -- helm buffers
the whole manifest and writes 0 bytes every time. With an empty file the
classifier already refuses ("no pod-bearing template in this render") and the
`--sources` call already carried `|| return 1`.

Fixed anyway, because "safe" was resting on two accidents this test asserts
neither of: helm buffering, and the classifier refusal. A helm that ever did
stream would reopen it in silence, and one word per arm is cheaper than that
dependency.

Mutation-proved: a late `fail` in requests-proxy-service.yaml now reddens
"coverage: every pod-bearing template is classified by at least one mode".
bats-hygiene 14/14 accepts the form.

drift 43/43, helm unittest 660/660, full bats 1647/1647 under v3.15.4 on the merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(qos): re-land the pod-level cpu/memory carve-out, verified under helm 3.15.4 (backend#2962)

KEP-2837 pod-level resources short-circuit above the per-container walk, so a
pod-level envelope of extended resources only (nvidia.com/gpu + ephemeral-storage
and nothing else) returned Burstable where ComputePodQOS says BestEffort: with
pod-level resources set the kubelet derives the class from them alone, filtered to
cpu/memory, and does not fall back to the containers. The per-container path already
carried this guard (backend#2871); this is its missing pod-level half.

The first attempt (51681eb) was reverted after CI reddened four --expect tests,
blamed on a helm-3.15.4-vs-4 render difference. Re-verified under the pinned helm
v3.15.4 (identical build to CI, v3.15.4+gfa9efb0): no chart workload renders
pod-level resources on any hostPath mode under 3.15.4 OR 4, so the carve-out is inert
on every chart render and cannot flip an --expect result. The reverted red was not
reproducible under the pinned helm in isolation, in a matched Linux amd64 container,
or in the full bats suite; its diagnostics were swallowed by the pre-_ok assertion,
so it was reverted without evidence. The suite is now 21/21 green under helm 3.15.4,
and the _ok helper (added since) means a re-land cannot go blind again.

The new pod-level BestEffort case is built so it cannot pass for the wrong reason:
its containers are themselves Guaranteed, so a fall-through would read Guaranteed and
the reverted no-carve-out branch read Burstable -- the case rejects both and only the
carve-out yields BestEffort.

Closes tracebloc/backend#2962

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Revert "fix(qos): re-land the pod-level cpu/memory carve-out, verified under helm 3.15.4 (backend#2962)"

This reverts commit e777910.

The carve-out encoded the wrong kubelet rule. Verified against the Kubernetes
source (pkg/apis/core/v1/helper/qos/qos.go + component-helpers/resource/helpers.go):
ComputePodQOS gates the pod-level branch on IsPodLevelResourcesSet, whose supported
set is {cpu, memory, hugepages} only -- an extended-resources-only envelope
(nvidia.com/gpu + ephemeral-storage) makes it FALSE, so with the
PodLevelResourcesFixKubeletQOSClass gate (Beta ~1.36) the kubelet FALLS THROUGH to
the per-container walk and returns Guaranteed when the containers are Guaranteed --
NOT BestEffort. The reverted commit returned BestEffort and asserted it with a test
built on Guaranteed containers, which is the pre-fix behavior and is wrong for the
1.34-1.36+ clusters this chart targets. Only latent (no chart renders pod-level
resources) kept it from being caught.

backend#2962's premise and acceptance criteria rest on that pre-fix behavior; the
finding is written up on the issue for re-scoping. Removing the wrong claim from this
PR rather than papering over it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Arturo Peroni <arturo@tracebloc.io>
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.

3 participants