Uh oh!
There was an error while loading. Please reload this page.
fix(chart): a declared CPU-only install must emit an explicit empty GPU_LIMITS (backend#2216) - #767
Conversation
…PU_LIMITS (backend#2216) THREE SITUATIONS, NOT TWO. jobs-manager's `_gpu_available_from_env` (client-runtime#80) distinguishes an EXPLICIT EMPTY `GPU_LIMITS` -- "no GPU on this cluster" -- from an ABSENT one -- "nobody said, assume a GPU", the legacy default. client#343 gated both vars on a NON-EMPTY `GPU_LIMITS`, which collapsed the first case into the second: an operator who wrote `GPU_LIMITS: ""` emitted nothing, so their edge was indistinguishable from a GPU install. THE COST, measured on client-runtime#355. On a CPU-only multi-node edge `_gpu_available` came back True, `_configure_resources` took the GPU branch, and every job requested `nvidia.com/gpu`. The pod wedged Pending, `check_pending_jobs` took the `elastic_no_gpu_node` fall-through, and it waited out the 600s scale-from-zero grace before being respun on CPU -- once per experiment. That is backend#1874's Pending-then-respin cycle, worse than the ~206s tax backend#2089 removed. So the gate is `hasKey`: key present emits it, empty value included; key absent emits neither and keeps the legacy default. #343's actual defect stays fixed, because the phantom GPU in the CLI resources view came from the `"nvidia.com/gpu=1"` DEFAULT, and an explicit empty is not that -- the default now applies only when `GPU_LIMITS` is non-empty. Both containers carry their own copy of the gate, so both are changed and both are asserted separately: a lone GPU request with no matching limit is rejected by the API server. THIS GATE HAD NO TEST AT ALL, in either direction, from #343 until now -- which is how one silence standing for two meanings survived. Six added, covering all three situations plus the phantom-GPU regression #343 was opened for. Mutation-proved: restoring the non-empty gate reddens 2, emitting the phantom default on a declared-empty install reddens 3, and emitting unconditionally (losing the can't-tell case) reddens 1. 488 chart tests pass; helm lint clean. Sequenced AHEAD of client-runtime#355, which is where @shujaatTracebloc found this: with the declaration restored, a CPU-only multi-node edge takes the CPU branch outright and does not need that PR's spawn-time gate at all.
shujaatTracebloc
left a comment
There was a problem hiding this comment.
Approving — the severed signal is genuinely restored, and I verified it end to end
This is the upstream half of client-runtime#355, and the reasoning in the template comment is the right one: hasKey vs non-emptiness is the difference between "the operator declared CPU-only" and "nobody said anything", and jobs-manager has always read those differently on purpose.
The declaration now actually reaches the runtime. Rendered both ways rather than trusting the tests:
--set env.GPU_LIMITS="" → GPU_REQUESTS: "" GPU_LIMITS: ""
--set env.GPU_LIMITS="nvidia.com/gpu=2" → GPU_REQUESTS: "nvidia.com/gpu=1" GPU_LIMITS: "nvidia.com/gpu=2"
no env.GPU_LIMITS at all → neither var
The first line is the fix. _gpu_available_from_env("", "") is False, so _configure_resources takes the CPU branch outright and that cluster shape stops needing the node-capacity gate at all — which is exactly what closes the CPU-only, SINGLE_NODE=false row recorded on #355.
And it reaches it on the paths that matter, which is the thing I most wanted to check, since hasKey is only a fix if somebody actually writes the key. Both installers write it unconditionally with an empty value for CPU-only — scripts/lib/install-client-helm.sh:1407 (gpu_val="") and scripts/install-k8s.ps1:4912 ($gpuVal = "", gated on K3D_GPU_FLAG per #616) — so every installer-provisioned CPU-only edge picks this up. Chart-direct installs that never mention GPU keep the legacy assume-a-GPU default, which is the right call and is what the fourth test pins.
The phantom GPU stays fixed, and for the reason stated: #343's defect was the "nvidia.com/gpu=1"default, not the var's presence. The new else if makes that explicit — an empty GPU_LIMITS now yields an empty GPU_REQUESTS rather than the default — and it is pinned in the right direction.
Mutation-proved, mine not the description's. Six tests, and I checked each one bites:
gate back to (default "" …) → 2 failed # the #343 bug itself
drop the empty-aware GPU_REQUESTS → 3 failed # incl. the phantom-GPU notContains
emit unconditionally (no gate) → 1 failed # "absent key emits NEITHER"
I was initially unsure the fourth test could bite, since notContains matches an exact name/value pair and would not catch the var being emitted with some other value — but under the realistic mutation both vars render as "", so it does redden. Good enough, and worth noting the gate had no chart test at all in either direction from #343 until now.
Chart version bumped in lockstep (1.9.48 → 1.9.49). The 4 locally-failing suites are identical on develop (5 failed, 5 errored both sides — a local helm/plugin-version artefact), and this PR moves the count 477 → 483 passed. CI green.
One non-blocking note, pre-existing and not yours to fix here
GPU_LIMITS: "nvidia.com/gpu=2" with no explicit GPU_REQUESTS renders requests: nvidia.com/gpu=1 against limits: nvidia.com/gpu=2. Kubernetes rejects extended resources whose request and limit differ, so that pod is invalid at admission — and _get_gpu_resources takes the env-override branch, so nothing downstream reconciles them. That asymmetry has been there since #343 and this PR does not change it.
The reason I mention it: the first new test now pins that pairing under the name "a GPU install emits both vars with the configured value" — and GPU_REQUESTS is not the configured value, it is the hardcoded 1. A future reader takes that name as a statement that 2/1 is intended. Either renaming it to say what it checks, or deriving the GPU_REQUESTS default from GPU_LIMITS, would do — happy for it to be a follow-up either way, it does not hold up the fix.
Uh oh!
There was an error while loading. Please reload this page.
saadqbal
left a comment
There was a problem hiding this comment.
Checked the three-state claim against the chart rather than the description, and it stands up:
values.yamlkeepsGPU_LIMITScommented out, so "key absent" is genuinely the default and the legacy assume-a-GPU path stays reachable. Had the chart shippedGPU_LIMITS: ""as a default,hasKeywould be always-true and a GPU install that forgot to set it would now declare itself CPU-only — that's the failure mode this would have had, and it isn't there.- The declared-empty case is real, not hypothetical:
install-client-helm.sh:1407writesGPU_LIMITS: "$gpu_val"withgpu_valempty on a no-GPU host, andinstall-k8s.ps1:5025does the same via--set-string. So every installer-provisioned CPU-only edge was hitting the collapsed silence. - Both gate sites changed, and the tests assert
containers[0]andcontainers[1]separately rather than assuming they agree — right call, since they're independent copies. hasKey .Values.envneeds.Values.envto be a dict, which is already assumed two lines up by theRESOURCE_LIMITSgate, so no new--reuse-valuesnil exposure.
Tests are derived rather than restated: the declared-empty test fails against #343's non-empty gate, and the absent-key test fails against an unconditional emit. That matches the mutation table.
One non-blocking note: GPU_REQUESTS: "" set explicitly alongside a non-empty GPU_LIMITS still emits a limit with an empty request. Pre-existing, no caller does it, and the installer can't — just noting it since the comment above the gate makes a point of request/limit travelling together.
The chart-version guard failed against the new base: client#767 landed on develop and claimed 1.9.49 while this branch was open, so relative to that base this PR bumped nothing. Same collision shape as two migrations picking the same number off one parent — the version has to be re-checked after develop advances, not chosen once when the branch is cut. Rebased onto develop (3c40ef9 -> 16adfc7) and moved to 1.9.50, version + appVersion in lockstep. Verified after the rebase: my provenance env block and #767's GPU_LIMITS gate coexist cleanly in jobs-manager-deployment.yaml; helm lint clean; helm unittest 486 passed vs 477 on develop with the SAME 5 pre-existing failures and 5 errors, so 9 added and none broken; embed check, manifest check, chart-version-guard 23/23 and the provenance/envelope/training-size bats all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chart-version guard failed against the new base: client#767 landed on develop and claimed 1.9.49 while this branch was open, so relative to that base this PR bumped nothing. Same collision shape as two migrations picking the same number off one parent — the version has to be re-checked after develop advances, not chosen once when the branch is cut. Rebased onto develop (3c40ef9 -> 16adfc7) and moved to 1.9.50, version + appVersion in lockstep. Verified after the rebase: my provenance env block and #767's GPU_LIMITS gate coexist cleanly in jobs-manager-deployment.yaml; helm lint clean; helm unittest 486 passed vs 477 on develop with the SAME 5 pre-existing failures and 5 errors, so 9 added and none broken; embed check, manifest check, chart-version-guard 23/23 and the provenance/envelope/training-size bats all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#768) * feat(envelope): record WHO chose the training envelope (backend#2220) Scope bullet 4 -- "migration for edges already pinned by resources set: they must not be stranded". The blocker was never the migration mechanics, it was that provenance is currently unknowable, which Lukas called the actual hard part of the ticket. This adds the marker that makes it knowable from now on, and is honest about the edges where it never will be. Why it cannot be inferred: RESOURCE_* has no unset state once helm's --reset-then-reuse-values has seen it (fleet auto-upgrade CronJob, installer reconcile, and the CLI's own apply all use it). _existing_training_resources carries any value forward unless it equals the historic cpu=2,memory=8Gi, and that is the ONLY discriminator available -- so an installer-written envelope and a deliberate `tracebloc resources set` are indistinguishable. Without a marker, future automatic sizing must either strand every pinned edge or silently overrule operators who chose a size on purpose. env.RESOURCE_PROVENANCE, three states: installer sized to this machine at install time user explicit `resources set`, or a TRACEBLOC_TRAINING_RESOURCES override unknown carried forward from before this key existed `unknown` is deliberate, not a placeholder. Guessing `installer` there would risk overruling a human, so consumers MUST treat unknown as user. Existing edges report unknown and keep their size until someone opts in explicitly -- offering that opt-in is a product decision, not something P0 should make silently. An existing marker is authoritative and is preserved across re-install, in both installers. Without that, a re-install would quietly downgrade a `user` choice to `unknown`, and the marker would be worse than useless -- it would decay toward the ambiguity it exists to remove. Pinned in bats and Pester. _training_resources is now a wrapper over _resolve_training_size, which returns the size AND the verdict from one pass. Deriving them separately would mean either running the bounded cluster probes twice or re-implementing the branch logic beside itself -- the exact duplication this ticket deletes. All 15 pre-existing size tests pass unmodified. The marker rides inside the same template gate as the pair it describes: a provenance marker with no envelope to describe is noise, and gating them together means one explicit unset clears all three. Also excluded from both generic env passthrough loops, or it would render twice per container. MIGRATION.md carries the un-pin recipe, VERIFIED rather than assumed: helm deletes null-valued keys during value coalescing, so `--set env.RESOURCE_*=null` drops all three vars and makes the derive path reachable -- confirmed by rendering the chart both ways (6 occurrences set, 0 with null). That is the explicit helm-side unset the ticket's trap 2 asks for, and it needs no chart change. The recipe carries a warning: derivation is gated OFF (backend#2167), so clearing the keys today returns the edge to the fixed literal, which on a sub-8GiB machine cannot schedule at all. Chart 1.9.48 -> 1.9.49, version + appVersion in lockstep. manifest.sha256 regenerated: both installers moved. Verified: bats 172 ok (same 3 pre-existing failures clean develop has, #64-66 -- they need real helm), 8 new provenance tests; Pester 750 passed / 0 failed, 7 new; helm unittest 480 passed vs 477 on develop, so 3 added and no new failures among the 5 pre-existing; helm lint clean; PSScriptAnalyzer 0 errors; gen-envelope-embed --check clean. Refs: RFC-BACKEND-664 P0, client-runtime#358, client#766, cli#538, backend#2167 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(chart): bump to 1.9.50 — 1.9.49 was taken by #767 The chart-version guard failed against the new base: client#767 landed on develop and claimed 1.9.49 while this branch was open, so relative to that base this PR bumped nothing. Same collision shape as two migrations picking the same number off one parent — the version has to be re-checked after develop advances, not chosen once when the branch is cut. Rebased onto develop (3c40ef9 -> 16adfc7) and moved to 1.9.50, version + appVersion in lockstep. Verified after the rebase: my provenance env block and #767's GPU_LIMITS gate coexist cleanly in jobs-manager-deployment.yaml; helm lint clean; helm unittest 486 passed vs 477 on develop with the SAME 5 pre-existing failures and 5 errors, so 9 added and none broken; embed check, manifest check, chart-version-guard 23/23 and the provenance/envelope/training-size bats all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(installer): one shared lookup for size + provenance (Bugbot + review, #768) Bugbot's Medium, confirmed by @saadqbal, and it was a fresh instance of the exact class client#766 exists to remove: the two installers diverging by construction, same file pair, days later, in the fail-unsafe direction. Get-TrainingProvenance performed its OWN `helm get values`, separate from the one Get-TrainingResources did immediately below, each wrapped in its own bare `catch {}`. So on Windows, if the size read succeeded and carried a live RESOURCE_LIMITS but the provenance read then threw -- a wedged API, a ConvertFrom-Json hiccup, anything the catch eats -- the generated values pinned that carried envelope as `installer`. A future ladder trusting that label would re-derive and overrule what may well have been a deliberate human choice. `unknown` is the safe verdict there, and the bash twin already gave it. Fixed by adopting the bash shape rather than patching the symptom: Get-CarriedTrainingValues does the lookup ONCE and returns @{Size; Provenance} or $null. That $null is the point -- either the read succeeds and both resolvers see the same carried pair, or it fails and NEITHER takes the carry path, so the size is machine-derived and `installer` is then correct. Size and provenance can no longer disagree, structurally, rather than by two functions happening to agree. The values generation now does that one lookup and hands it to both via -Carried/-CarriedResolved, which also drops the redundant `helm get values` per install that @saadqbal noted. Both params are optional, so every existing caller and test that invokes the resolvers bare is unaffected. Four Pester tests added for the fail-unsafe path specifically: a values read that returns unparseable JSON must carry nothing and answer `installer` (not pin a carried size as ours); one lookup handed to both resolvers must agree; the historic literal is still treated as the absence of a choice even with a marker beside it; an unreadable namespace carries nothing without throwing. Also rebased onto develop to clear the DIRTY merge state. The only conflict was scripts/manifest.sha256 -- a GENERATED file, so it was regenerated rather than hand-merged. Chart stays at 1.9.50, still ahead of develop's 1.9.49. Verified: Pester 754 passed / 0 failed (+4); bats 175 ok / 0 failed -- develop's #759 fixed the three helm-dependent tests that were failing before, so this branch is now clean rather than clean-except-three; helm lint clean; helm unittest 486 passed with the same 5 pre-existing failures develop has; PSScriptAnalyzer 0 errors; make lint clean over 47 files; embed + manifest checks clean. Refs: backend#2220, client#766, cli#539 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(installer): never write an envelope larger than the machine (backend#2220) (#769) A machine below the training floor was handed cpu=2,memory=8Gi. On a box with ~4 GiB allocatable that envelope is LARGER THAN THE MACHINE, so every training pod stays Pending forever -- precisely the failure install-k8s.ps1's own header says the machine sizing exists to remove ("dead on arrival on nodes under 8 GiB, the WSL2 field case"), recreated by its own fallback. And --reset-then-reuse-values then re-applies that value on every upgrade, forever. Not a hypothetical band: preflight hard-fails below 5 GB on Linux and only WARNS on macOS/Windows (PF_MIN_MEM_GB=5, PF_WARN_MEM_GB=8), while its own comment notes "16 GiB is needed to train locally (a job's limit is ~8 GiB+)". The permitted band and the unschedulable band overlap. Root cause was that two situations produced one answer: "I cannot read the machine" and "I read it and it is too small" both returned empty and both fell through to the literal. Now separated: unreadable -> the literal, unchanged. We cannot do better, and warning about machine size would be a fabrication. below the floor -> the honest remainder (>= 1 core and >= 1 GiB), which FITS. A run can be scheduled and fail for a REASON instead of hanging, and the operator is warned. below even that -> the literal plus a hard warning. cpu=0 is not a training request, and refusing the install would break the run-but-do-not-train-locally case preflight allows. The warning lives in the CALLER, never in the resolver: _training_resources and _training_provenance are captured with $(...) and their tests compare the whole output, so a warn emitted from the resolver would land inside RESOURCE_LIMITS. Pinned by a test that captures the value and asserts it is clean. _machine_training_resources keeps its exact old semantics (empty when non-viable), so its callers and its golden replay are untouched; the new viability-aware _machine_training_ceiling sits beside it. Two expectations CHANGED rather than added, and both encoded the bug -- the bats and Pester "below-floor machine falls back to the static default" tests each asserted the unschedulable literal as if it were the contract. Neither loses coverage: the unreadable-cluster tests still pin the case where the literal is genuinely right, now a distinct branch rather than the same one. The Pester golden replay is strengthened, not just repaired. It mapped EVERY non-viable vector onto the literal, which is what let this bug live inside a passing replay; it now checks them against the contract's own render_gi. Those numbers already matched -- wsl2-too-small-4gi renders 3c/1Gi, exactly what the installer now writes -- so the accessor and the installer agree on small machines too, and that agreement is asserted. Rebuilt on top of the current #768 rather than merged into it. #768 has since taken review fixes and a develop merge that restructured the same two sizing functions, so a replay produced seven conflict hunks across both installers -- the serialize-don't-parallelize hazard the ticket itself warns about. Re-applying this self-contained delta was safer than hand-resolving them; the test files applied from the original patch unchanged. Verified: bats 179 ok / 0 failed (develop's #759 fixed the three helm-dependent tests that used to fail); Pester 758 passed / 0 failed; manifest + envelope-embed checks clean. Refs: RFC-BACKEND-664 P0, client-runtime#358, client#766, client#768, backend#2167 Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(installer): one shared lookup in the bash twin too (Bugbot #768) I fixed this on the PowerShell side last push and left the bash side split -- which is precisely the two-installers-diverge class client#766 exists to remove. Bugbot caught the half I missed, and its failure mode is the MIRROR of the ps1 one: ps1 a failed provenance read reported `installer` for a carried size, inviting a future ladder to overrule a human choice bash a failed provenance read reported `unknown`, which consumers treat as a human pin -- so an installer-sized edge was PERMANENTLY STRANDED as a deliberate choice The second is worse, and it is the exact outcome scope bullet 4 exists to prevent: stranding is the defect, and a fail-unsafe read was manufacturing it. _existing_training_values now does ONE `helm get values` and echoes "<size>|<provenance>", or nothing. Nothing is the important case: a failed or empty read carries NOTHING, so the caller machine-sizes and `installer` is then the correct verdict, keeping the edge eligible for a future ladder. Provenance is normalised in that one place -- anything unrecognised, including absent, is `unknown`, never a guess. _existing_training_resources and _existing_training_provenance are now thin readers over it, so they cannot disagree, and _resolve_training_size takes both fields from a single call. All 15 pre-existing size/provenance tests pass UNMODIFIED, and the sizing path is down to exactly one `helm get values` (the other in the file is the unrelated reconcile path). Five tests added on the fail-unsafe path specifically: a failed read and an empty read must each carry nothing; a failed read must leave the verdict at `installer` rather than a stranding `unknown`; one lookup returns both fields together; and a carried size with genuinely no marker still pairs with `unknown` -- because `unknown` is correct when the read SUCCEEDS and there is no marker. The bug was reporting it when the read failed. Verified: bats 180 ok / 0 failed (+5), bats-hygiene 18/18, shellcheck -S warning -x clean on the edited script, manifest + envelope-embed checks clean. Refs: backend#2220, client#766, client#769 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(manifest): regenerate for the shared-lookup change The digest for scripts/lib/install-client-helm.sh was stale in 1d0b72d: I regenerated the manifest in the working tree AFTER committing, then reverted that working-tree change instead of committing it. The rebase had resolved the manifest conflict with --ours, which is never right for a generated file. Caught by running gen-manifest.sh --check against the pushed tree rather than trusting the earlier "up to date" line, which had been printed after a regeneration that never made it into a commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(installer): only call a machine too small once we've measured it (Bugbot #768) Third instance of the same divergence class in this PR, and this one I wrote into my own commit message and then contradicted in the code: "warning about machine size would be a fabrication" -- while the code fabricated exactly that. _TB_TRAINING_UNSCHEDULABLE was INFERRED by re-probing `kubectl get nodes -o name` whenever the ceiling helper returned nothing. But the helper returns nothing for three different reasons, and only one of them is "too small": no kubectl, an unreadable/failed node read, nothing parseable, OR a parsed node whose remainder is not a requestable shape. So any listable node -- one whose allocatable would not parse, or one not Ready yet -- tripped a hard "this machine is too small" warning plus the cpu=2,memory=8Gi literal, about a machine we had never successfully measured. The PowerShell twin only flagged it after a PARSED node ($seen), so the two installers disagreed on the same cluster. Fixed at the source rather than at the caller: _machine_training_ceiling now returns "|unschedulable" as its own verdict when it HAS parsed a node and the remainder is not requestable, and stays silent only when it genuinely could not measure. Silence now means one thing -- "I could not read the cluster" -- and the resolver keys off the verdict instead of guessing from a second probe. That also drops the redundant `kubectl get nodes` call. Three tests, the first of which is the bug: readable-but-unparseable nodes must NOT be called too small; the helper reports |unschedulable only after measuring; the helper stays silent when nothing parses. The existing "too small for even a 1c/1Gi run" test still passes -- that node parses, so it is a measurement. Verified: bats 187 ok / 0 failed (+3), bats-hygiene 18/18, shellcheck -S warning -x clean, manifest + envelope-embed checks clean. Refs: backend#2220, client#766 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes part of tracebloc/backend#2216 — the upstream half. Found by @shujaatTracebloc reviewing tracebloc/client-runtime#355.
Three situations, not two
jobs-manager's
_gpu_available_from_env(client-runtime#80) reads three states, deliberately:GPU_LIMITS: "nvidia.com/gpu=2"GPU_LIMITS: ""(explicit empty)client#343gated both GPU vars on a non-emptyGPU_LIMITS, which collapsed rows 2 and 3 into one silence. An operator who wroteGPU_LIMITS: ""emitted nothing, so their edge was indistinguishable from a GPU install.What that cost
Measured on client-runtime#355, on a CPU-only multi-node edge:
_gpu_availablecame backTrue— the declaration had been thrown away;_configure_resourcestook the GPU branch and every job requestednvidia.com/gpu;check_pending_jobstook theelastic_no_gpu_nodefall-through;That is backend#1874's Pending-then-respin cycle, and worse than the ~206s tax backend#2089 removed.
The change
The gate becomes
hasKey: key present → emit it, empty value included. Key absent → emit neither.#343's actual defect stays fixed. The phantom GPU in the CLI resources view came from the"nvidia.com/gpu=1"default, not from the var being present — and that default now applies only whenGPU_LIMITSis non-empty. An explicit empty renders as an explicit empty.Both containers carry their own copy of the gate, so both are changed, and both are asserted separately rather than assumed to agree — a lone GPU request with no matching limit is rejected by the API server.
This gate had no test at all
From
#343until now, nothing asserted the GPU env vars in either direction — which is how one silence standing for two meanings survived. Six tests added, covering all three situations plus the phantom-GPU regression#343was opened for.Mutation-proved:
#343's non-empty gate (the exact defect)488 chart tests pass;
helm lintclean. Chart bumped 1.9.48 → 1.9.49.Sequencing
Ahead of client-runtime#355. With the declaration restored, a CPU-only multi-node edge takes the CPU branch outright and does not need that PR's spawn-time gate at all — which is exactly why @shujaatTracebloc asked for this to land before/with it.
Note
Cursor Bugbot is generating a summary for commit 98f1547. Configure here.