fix(worker): scope node-devices feasibility to each podset's own flavor - #159
fix(worker): scope node-devices feasibility to each podset's own flavor#159thxCode wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Flavor scopes can still include nodes outside Kueue’s assigned device-count batch, producing false Ready verdicts.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Scopes accelerator feasibility by PodSet flavor and adds role-aware verdicts with regression coverage.
Changes:
- Correlates demands and cards with assigned flavors.
- Adds explicit handling for missing flavor assignments.
- Expands unit and reconcile-level tests.
Standards: One correctness blocker remains around dropped .count selectors.
Spec: Single-PodSet and partition-ledger messages do not fully match the stated behavior.
Over-engineering: Lean already.
File summaries
| File | Description |
|---|---|
node_devices_admission.go |
Implements flavor-scoped feasibility and role-aware verdicts. |
node_devices_admission_test.go |
Adds flavor, mixed-model, and reconciliation regressions. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 3
- Review effort level: Balanced
98748b1 to
398d50e
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Single-PodSet messages regress and one partition Retry path still omits the affected role.
Review details
Suppressed comments (2)
pkg/worker/controllers/worker/node_devices_admission.go:92
- Every production demand now records its PodSet name, so an ordinary one-PodSet workload passes
["main"]to the message helpers and changes messages such as the Ready verdict to includefor role "main". This contradicts the PR's explicit guarantee that single-PodSet verdict messages remain unchanged. Keep the PodSet provenance for assignment validation, but render role qualifiers only when the workload actually has multiple PodSets.
d.podSets = []kueue.PodSetReference{ps.Name}
pkg/worker/controllers/worker/node_devices_admission.go:531
- The ledger-not-ready branch still returns the constant message without
d.podSets, so a multi-role partition workload held because one flavor's placement ledger is missing does not identify the affected role. This leaves one Retry path inconsistent with the PR's role-specific verdict requirement; route this branch through a helper that appendsforRoles(d.podSets)as the other partition verdicts do.
return kueue.CheckStateRetry, partitionNoCardsMessage(d.profile, d.podSets)
case ledgerReady == 0:
return kueue.CheckStateRetry, partitionLedgerNotReadyMessage
}
return kueue.CheckStateRetry, partitionVerdictMessage(kueue.CheckStateRetry, d.profile, d.podSets)
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
🔍 OpenCodeReview found 1 issue(s) in this PR.
|
398d50e to
ea82edf
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Valid dotted accelerator IDs and nondeterministic candidate ordering can produce incorrect Retry verdicts.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Balanced
ea82edf to
44cc93e
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Fail-open count parsing, incomplete role attribution, and a probabilistic regression test remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
pkg/worker/controllers/worker/node_devices_admission.go:478
withoutSoleRolecounts only PodSets that produced accelerator demands, not all PodSets in the Workload. A multi-PodSet Workload with one GPU role and one CPU-only role therefore loses the GPU role from a shortage verdict, despite the PR's requirement to identify the affected role. Pass the Workload's actual PodSet count (or an explicit multi-role flag) into feasibility instead of inferring its shape fromdemands.
demands = withoutSoleRole(demands)
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Balanced
44cc93e to
ab638e4
Compare
|
Round 3 triage — 3 findings, all verified real against source, all fixed in Suppressed comment on The role clause was stripped whenever the demands named at most one podset. A Workload whose accelerator role sits beside a CPU-only one parses to exactly one demand carrying one name — indistinguishable from a plain Pod's — so the role was dropped from the verdict, which is precisely the name a prefill/decode deployment needs. The rule now comes from Two consequences worth stating:
Nothing was rejected this round. Findings across the three rounds: 3 + 2 + 3, all real, no false positives. One observation on the tooling, not on the review: |
ab638e4 to
3ee9fa2
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Same-family requests spanning multiple accelerator bases can still be incorrectly reported Ready.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
3ee9fa2 to
4e33757
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Greedy card selection can return Retry for feasible workloads with overlapping flavor coverage.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Balanced
4e33757 to
26bb4c1
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The effective no-op guard can preserve a missing retry delay and cause immediate Kueue retry loops.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
The per-accelerator AdmissionCheck flattened two things that must stay
correlated: parseFamilyDemands folded every podset's demand into one list,
and collectCards flattened every candidate node's accelerators into one
list with no record of which flavor — or which accelerator model — each
card belonged to. nodeDevicesFeasibility then fitted the merged demands
against the merged cards, resting on an assumption its own doc comment
stated: "already scoped to one flavor pool by label".
That assumption holds for a single-flavor Workload and fails for two
reachable shapes:
- A multi-podset Workload. Kueue assigns a ResourceFlavor per podset, so
a Workload whose roles want different accelerator models carries two.
Measured before the fix, a Workload demanding 2 cards under an
exhausted flavor and 2 under a free one parsed to a single merged
demand ("exclusive cards=4") and was reported Ready, satisfied
entirely from the free flavor's cards. Kueue admits it and the starved
role's Pods stay Pending forever.
- A node carrying two accelerator models. It publishes ONE Devices
object holding both models' cards, so even a single-flavor demand was
judged against models its flavor does not cover.
Both are the failure an admission gate can least afford: a legal answer,
no error raised anywhere, and a workload that never runs.
A demand now carries the flavor assigned to its podset, and merges with
another only when that flavor also agrees — so two roles on one flavor
still share a budget while two on different flavors do not. A card now
carries the assigned flavors that cover it: those whose selector matched
its node AND whose nodeLabels pin its own accelerator key. The per-card
budget stays global to the Workload, so a card a mixed-model node
contributes to two flavors is still spent once.
A demand whose podset carries no flavor assignment is held explicitly,
with a verdict naming that as the reason. The fit would hold it anyway —
an unnamed flavor is covered by no card — but a pool-wide stall reported
as "no node has enough free cards" about a pool that is short of nothing
is a false negative reached indirectly, and this state is severe enough
that its cause must be stated. Kueue's own SetQuotaReservation
dereferences the admission it stores, so it cannot produce a reserved
Workload without one; reaching this means the status was written from
outside Kueue, or Kueue's contract moved.
Verdicts now name the role they are about, so a prefill/decode deployment
held on capacity says which half fell short.
Behaviour for a single-podset Workload is unchanged: the existing case
table runs untouched and its verdicts and messages are identical.
Three test-quality repairs ride along, all of the same shape — a case whose
name and comment claim one thing while its code exercises another:
- A flavor fixture carried no bare accelerator feature key, and an
admission fixture carried no PodSetAssignment.Name. Kueue's scheduler
writes that name from the podset's own and the CRD defaults it to
"main", but the fake client does neither, so the gate answered Retry
for an unresolvable card population rather than for the units shortage
those cases are built on: still green, exercising nothing.
- A population case claimed a card was excluded from the partitioned
population, but asserted only the verdict state — and an empty profile
ledger yields the same state, so the claim went unpinned. This change
makes that worse by adding coverage as a third route to it, so the case
now asserts the message too.
- A partition case named for two cards gave the first card seven free
placements, so both requested instances came from it and the second was
never examined. One placement each now forces the traversal.
Both Retry paths through the reconcile helper now assert WHY the workload
is held rather than only that it is, since a state-only assertion is what
let the first pair hide.
A guard test lands in node_queue_test.go for an assumption this file makes
about another: assignedFlavor resolves a podset's card population from the
single flavor its assignment carries, which holds only while a ClusterQueue
covers exactly one resource. Give an accelerated queue a second covered
resource and a podset carries two flavors, one of them a CPU flavor pinning
no accelerator key — it would cover no card, and every accelerator workload
of the pool would sit in Retry reporting a shortage against a pool short of
nothing. The dependency is invisible from buildResourceGroups and a comment
recording it would be one more hand-maintained note, so the guard fails at
the place the assumption could be broken instead.
An automated review of this change raised three findings, all of them real
and all fixed here:
- A flavor's ".count" node batch was not surviving into the card scope.
The batch is part of a flavor's identity — its name encodes the
per-node card count and Kueue admits Pods onto that batch only — but
the Devices selector cannot carry it, because the DeviceManager omits
".count" from a Devices object's labels. Dropped from the list and not
re-applied, the 4-device and 8-device flavors of one model claimed each
other's nodes: free cards on a 4-device node made an 8-device-flavor
demand Ready and left its Pods Pending. The batch is now re-applied per
card group, and a flavor that pins no batch still covers every batch —
reading a missing label as "none" would make its workloads unplaceable.
- The claim that a single-PodSet Workload's verdict is unchanged was
false in production. Every parsed demand now carries its PodSet name,
so an ordinary one-PodSet Workload gained `for role "main"` — noise
naming Kueue's default podset. The direct tests preserved the old text
only because their hand-built demands carry no provenance, which is the
same "green while exercising nothing" shape as the fixtures above. The
role clause exists to say WHICH role fell short, so it is now emitted
only when the Workload names more than one, and the claim holds.
- The ledger-not-ready partition branch returned a role-less constant
while every other capacity verdict named its role. Its ledger-ready
count is taken over the cards that role's own flavor covers, so an
unqualified wording claims a rollout window over a pool another role is
running on. It names the role now, and the two message forms are one
function rather than a constant beside it.
Each fix is checked by mutation, and the third one twice: the first test
written for it asserted the message function directly, so replacing the
call site's provenance with nil did not fail it — a test that pins the
wording but not the wiring. It now asserts through nodeDevicesFeasibility.
A second review round on the fixes above raised two more, both real:
- `flavorAcceleratorKey` discarded any label key containing a period,
treating it as a metadata suffix. But a device group id may legally
contain one — `device.NormalizeName` preserves "-", "_" and "." — so a
real identity like `nvidia-foo.v2` was read as no key at all, leaving
the scope keyless and holding every workload on that flavor in Retry
with its cards free. What separates the bare key from its `.count` /
`.product` / `.memory` siblings is their VALUE, not the key's shape:
the siblings carry something other than "true". The predicate is now
`nodefeature.ExtractAcceleratableNodeKeys`' — value "true" plus a first
segment that is a known acceleratable manufacturer — so the two places
that decide what an accelerator key is cannot disagree.
- The candidate node order was documented as stable and was not. It was
built while ranging a SET of flavor references, whose iteration order
Go randomizes, and the List behind each carries no ordering guarantee
either. Since the fit spends a per-card budget in list order, that can
change which eligible card a demand consumes. Sorted by node name now,
and the comment states what is actually guaranteed.
A third round raised three more, all real:
- `flavorAcceleratorCount` failed OPEN on a ".count" node batch it could
not read: an unparsable or non-positive value was widened to "any
batch", the same answer an absent label gets. The two are opposites.
Kueue admits a flavor's Pods only onto nodes carrying that exact label
value and no node can carry an unreadable one, so widening it lets free
cards from another batch report Ready for Pods that will never be
placed there — while a flavor that states no batch legitimately covers
every one. The absent case still reads as "any"; a present but
unreadable one now covers no card, and a label present with an empty
value is told apart from an absent one by the lookup rather than by the
parse.
- The rule deciding whether a verdict names a role was read off the
parsed demands rather than off the Workload. A Workload whose
accelerator role sits beside a CPU-only one parses to exactly one
demand carrying one name — indistinguishable from a plain Pod's — so
its role was stripped from the verdict, dropping the name a
prefill/decode deployment most needs. It is now read from
len(wl.Spec.PodSets) in Reconcile, once, so the capacity verdict and
the unassigned-flavor hold agree.
Two consequences. The sole-role strip left nodeDevicesFeasibility,
which cannot know a Workload's shape and no longer claims to. And
unassignedRoles split into "which demands lack a flavor" and "which
roles to name", because it had conflated the two: it found the hold BY
role name, which a single-podset Workload no longer carries, so leaving
it whole would have silently stopped holding those Workloads at all.
Checked by mutation in that direction too.
- The candidate-order regression test could pass with the sort removed.
Its two flavors matched disjoint node sets whose concatenation happened
to equal the sorted order half the time, so it detected the defect
probabilistically — and so did the mutation that was meant to prove it.
The fixture now interleaves them: one flavor holds node-a and node-c,
the other node-b, and since each flavor contributes a contiguous block,
node-b can never land in the middle. Neither iteration order can
produce the sorted result, so a single pass is a verdict. Removing the
sort now fails 8 runs out of 8, where the old fixture needed 25 rounds
to be confident.
A fourth round raised six, from two reviewers. Two were taken as written, one
was answered by DELETING a test this change had added, and three were answered
in comments after their claims were checked and found unreachable or already
enforced:
- The label predicate that decides which card population a role is scoped to
was a private copy of nodefeature's — and that copy is exactly what drifted
in the previous round. It is now nodefeature.ExtractAcceleratableKeys, a
labels-map extractor beside the labels writer, with the Node-based
extractor delegating to it. Mutating the predicate turns tests red in BOTH
packages, which is what proves the controller reads the shared one rather
than a stale copy. The same function also picks deterministically: a flavor
pinning several acceleratable keys violates the writer's invariant, and a
violation must not make the covered population depend on map iteration
order.
- nodeDevicesFeasibility's doc comment still described its input as "already
scoped to one flavor pool by label" — the invalid assumption this change
removes. It now says what the input is: every node the assigned flavors
reach, with each demand fitted against its own flavor's cards.
- TestCandidateDevices indexed pool[0] after a non-fatal assertion, so a
regression returning an empty pool would panic instead of reporting the
failure. The assertion is fatal now; with the pool forced empty the test
fails on the message rather than on an index.
- The guard added in node_queue_test.go last round is DELETED. Review showed
it duplicated TestNodeQueueReconciler_FillsAndSortsByCount, which already
asserts one resource group and one covered resource, for a CPU-only and an
accelerated queue, through the full reconcile path. Verified rather than
accepted: widening an accelerated queue's CoveredResources turns that
reconciler case red, so the guard enforced nothing the suite did not. What
it recorded is stated at assignedFlavor instead, including what an admin's
own queue breaking the assumption does — a CPU flavor pins no accelerator
key and covers no card, so the demand is held with Retry. The choice of
flavor there is arbitrary; the direction is not: a flavor that reaches no
card can only withhold capacity, never invent it.
- A podset may legally name two manufacturers' bases in one family — the Pod
webhook forbids two families, not two bases — and podSetFamilyDemands
merges them into one tuple, which would then be fitted against a single
manufacturer's cards. Checked against the scheduling chain rather than
fixed: each manufacturer's keys transform onto that manufacturer's OWN
credits resource, an accelerated ClusterQueue covers exactly one of them,
and Kueue refuses to assign a flavor for a requested resource its queue
does not cover, so such a Workload is never reserved and no verdict is ever
asked of it. Restructuring the demand around a manufacturer would buy
nothing today, so the two properties that keep it out are written down
where the merge happens instead.
- assignedFlavor's tie-break drew the same review a second time, from the
other reviewer. The answer is unchanged and now recorded at the function:
our own queues cover exactly one resource, and an admin's queue that covers
more can only cost capacity, never invent it.
The determinism case for the shared extractor is the one property in this
change a single pass cannot settle, and the reason is worth stating: the wrong
implementation is not wrong, it is random. Where an unstable ORDER could be made
observable by interleaving the fixture, no fixture makes every wrong draw of a
single key observable — some draw always starts with the smallest. So the
instrument is a loop, and six keys put the odds of a wrong implementation
surviving eight passes at one in 6^8.
A fifth round raised four, and answering one of them found a defect neither
reviewer had reported:
- applyVerdict's no-op guard did not work, and had not since it was written.
It compared the incoming verdict against the object Kueue's PatchStatus
hands an update function — and PatchStatus replaces that object with a bare
server-side-apply skeleton carrying no status at all, so the check was never
found, the guard passed everything through, and every reconcile re-applied.
What hid it is that an apply of identical content is a no-op on the API
server: no event, no resourceVersion bump, nothing to notice. The comparison
now reads the Workload as fetched.
This was found by mutating the fix below and watching the mutation SURVIVE.
The review that prompted it argued the opposite — that the guard skips a
write and pins a stale message — which is what the guard was written to do
and not what it did. Both halves are needed together, and that is the point:
a working guard that compared only the state WOULD pin the first cause onto
the Workload for as long as the state held, because a verdict now names its
cause and its role. So the message is part of the comparison.
- assignedFlavor now picks the flavor assigned for the accelerator's OWN
resource. A podset's assignment maps one flavor per covered resource, and
the demand this check gates is always for an accelerator, so the credits
resource names the right entry; the previous name-ordered minimum could
take a CPU flavor, which pins no accelerator key and covers no card. Our own
queues cover one resource and are unaffected, but an admin's queue covering
both would have held every accelerator workload of that pool in Retry with
its cards free. The predicate for "is this an accelerator's credits
resource" lands beside the function that spells that name.
- The fit is greedy and that is now stated where it commits. Each demand takes
the first cards it may use and never gives one back, which is exact while
the populations are disjoint or nested the way the flavors this operator
builds make them: a card carries one model, and one model's flavors differ
by node batch. Where an admin's selectors overlap asymmetrically, a broad
demand can take the card a narrow one also needed. A matching over demands
and cards would answer it; the reason it is not here is the direction of the
error, which no order can flip: a demand only takes cards it may itself use,
so the fit reports a shortage that is not there, never room that is not
there.
- The determinism case's stated odds were wrong and are corrected. Go rotates
a small map's single bucket rather than permuting its keys, and the two
empty slots of a six-of-eight bucket skew where a rotation lands, so the
per-draw odds are neither 1/6 nor uniform, and 6^8 was a closed form for a
model that does not apply. The claim is now only what it can be — the odds
are well under one and compound across draws — plus what was measured: the
keys[0] mutation reddened five runs of five. The loop stays, because this
property admits no deterministic test: a random draw sometimes starts at
the smallest key already, and no fixture removes that, since some draw
always does. That is what separates it from the unstable ORDER of an earlier
round, where interleaving the fixture made every wrong draw observable.
A sixth round raised six. Four are taken, one is taken in a stronger form than
asked, and one is a fixture repair the others uncovered:
- The no-op guard compared state and message but not the requeue delay, which
is the field whose absence costs the most: Kueue reads a missing delay as
"retry now", the hot loop the fixed backoff exists to prevent. It compares
the whole verdict now. The lesson generalises past this field — anything
left out of that comparison is a field the guard pins to whatever reached
the Workload first, which is what the round before had just demonstrated.
- assignedFlavor no longer falls back to a flavor assigned for another
resource. Returning the CPU flavor left the demand carrying a flavor that
covers no card, so it slipped past the explicit hold and came back as a
capacity shortage against a pool short of nothing; returning empty routes
it to the hold that states the assignment as the cause. The hold's wording
and its doc now say "no ACCELERATOR flavor assignment", because that cause
has a fourth shape: an admission that carries flavors, none of them for an
accelerator.
- A flavor pinning several acceleratable keys now resolves to NO key rather
than to the smallest. This was raised as a test-determinism complaint, and
the test was the symptom: the choice itself was arbitrary. A flavor pinning
two model keys covers genuinely ambiguous cards, so scoping a role to one of
them is a guess, and the answer that cannot be wrong is to cover nothing and
hold the demand. That also makes the property testable, which no arrangement
of the previous test could: every draw over an ambiguous flavor now returns
the same empty string, so one pass is a verdict instead of a coin flip, and
the loop and its probability argument are gone.
- IsAcceleratableCreditsResourceName gained the direct table test its package
keeps for such predicates. The behaviour test in the controller exercised
the prefix but never the known-manufacturer half; dropping that half now
reddens the two negative cases.
- Eight assignment fixtures keyed their flavors by the bare word "credits",
which is not a resource name this operator ever writes — Kueue accounts the
manufacturer's credits resource, which the chart's transformation puts in
place of the raw accelerator keys. Every one of those fixtures was therefore
exercising the fallback path rather than the lookup production takes. They
now use the real name through nodefeature, and the shorthand is pinned as
NOT a credits resource in the new table, so the next fixture cannot repeat
it silently. This is the same shape as the missing PodSetAssignment.Name of
an earlier round: a fixture that is not what production produces answers a
different question, in green.
- The greedy-fit finding of the previous round was raised again as reachable
through overlapping admin flavors; the answer is unchanged and stated at the
function, because no ordering can flip the direction of the error.
Signed-off-by: thxCode <thxcode0824@gmail.com>
26bb4c1 to
3b6f6b6
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Partial SSA payloads can prune unchanged AdmissionCheck verdicts when another owned check changes.
Review details
Suppressed comments (1)
pkg/worker/controllers/worker/node_devices_admission.go:962
- When this controller owns multiple AdmissionChecks, an unchanged check is omitted from the server-side-apply payload whenever a sibling check needs updating. Since this field owner previously applied the omitted list item, SSA can prune its owned state/message/requeue fields, resetting a settled check as the sibling changes. Compute whether any verdict differs first, but whenever a patch is needed populate the apply skeleton with every entry in
checks; a two-check regression test should cover one unchanged and one changed entry.
if cur := kueueadmissioncheck.FindAdmissionCheck(wl.Status.AdmissionChecks, name); cur != nil &&
cur.State == acs.State && cur.Message == acs.Message &&
ptr.Equal(cur.RequeueAfterSeconds, acs.RequeueAfterSeconds) {
continue
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Balanced
| var credits kueue.ResourceFlavorReference | ||
| for res, ref := range psa.Flavors { | ||
| if nodefeature.IsAcceleratableCreditsResourceName(res) && (credits == "" || ref < credits) { | ||
| credits = ref | ||
| } | ||
| } | ||
| return credits |
There was a problem hiding this comment.
The < tie-break over psa.Flavors silently picks an arbitrary flavor when a podset legitimately carries two acceleratable credits resources (credits.gpustack.ai/<manufacturer> for two manufacturers). This is reachable: the Pod webhook forbids two families but not two bases (this file's own podSetFamilyDemands comment acknowledges "two manufacturers' bases"), and an admin-authored ClusterQueue can cover both manufacturers' credits (the assignedFlavor doc already concedes admin queues covering "more" reach this path). Because podSetFamilyDemands keys demands by ResourceFamily only — not manufacturer — a two-manufacturer podset collapses into one demand whose cards sums both models but whose single flavor is whichever ref sorts first, scoping the merged demand to one manufacturer's cards while counting the other's. Note the asymmetry with flavorAcceleratorKey a few lines below, which deliberately fails closed on ambiguity (returns empty → held) precisely because "a model chosen arbitrarily" is the wrong answer. Recommend the same treatment here: when more than one acceleratable credits resource is present (and they resolve to different flavors), return empty so the demand is held with an explicit 'ambiguous assignment' verdict rather than fitted against an arbitrary population.
What type of PR is this?
/kind bug
/area worker
What this PR does / why we need it:
The per-accelerator AdmissionCheck (gate 3) flattened two things that must stay correlated, and the
result is the failure an admission gate can least afford: a legal answer, no error raised
anywhere, and a workload that never runs.
parseFamilyDemandsfolded every PodSet's demand into one flat list.collectCardsflattened every candidate node's accelerators into one list with no record ofwhich flavor — or which accelerator model — each card belonged to.
nodeDevicesFeasibilitythen fitted the merged demands against the merged cards, resting on anassumption its own doc comment stated: "already scoped to one flavor pool by label". That holds
for a single-flavor Workload and fails for two reachable shapes:
roles want different accelerator models carries two. Measured before the fix, a Workload
demanding 2 cards under an exhausted flavor and 2 under a free one parsed to a single merged
demand (
exclusive cards=4) and was reported Ready, satisfied entirely from the freeflavor's cards. Kueue admits it and the starved role's Pods stay
Pendingforever.Devicesobject holding bothmodels' cards, so even a single-flavor demand was judged against models its flavor does not
cover. This one needs no multi-role workload at all — it is reachable today.
What changes
also agrees — so two roles on one flavor still share a budget while two on different flavors
do not.
whose
nodeLabelspin its own accelerator key. Coverage is plain equality on the flavorreference in both directions — no sentinel, and no "empty means any".
flavors is still spent once.
.countlabel means "any batch" — a flavor that states none must not be narrowed to none — while a value
that is present but not a positive card count covers no card at all, because Kueue admits
that flavor's Pods only onto nodes carrying the exact value and no node can carry an unreadable
one.
that as the reason. The fit would hold it anyway, but a pool-wide stall reported as "no node has
enough free cards" about a pool short of nothing is a false negative reached indirectly.
which half fell short.
Behaviour for a single-PodSet Workload is unchanged, verdict messages included: the existing
case table runs untouched.
Which issue(s) this PR links to:
None — found while specifying the cross-role PD work.
Special notes for your reviewer:
mainto be reviewable. It was found whilespecifying cross-role PD (prefill/decode) workloads, where two roles of one deployment can be
assigned two ResourceFlavors — but the second defect above needs no multi-role workload at all,
which is why the fix ships on its own rather than waiting for a CRD that does not exist yet. The
design document for that wider work stays on a branch until its implementation starts.
(an empty pool must
Retry) proving the query itself worked.explicit unassigned-flavor hold turns its cases red with exactly the false message the hold
exists to prevent — "no node has enough free cards" against four completely free cards.
Reconcile, because the three pieces (reading a PodSet'sassigned flavor / scoping a node's cards / filtering the fit) can each be right while the
wiring between them is wrong.
structurally cannot answer: read each test case's code only — no name, no comment — infer what it
actually exercises, then report every place that disagrees with what it claims. The author has a
prior about their own comments; the reviewer does not. It found two disagreements, both in
cases predating this change, and both fixed here (the third and fourth bullets below).
exercising another: one flavor fixture carried no bare accelerator feature key, and one
admission fixture carried no
PodSetAssignment.Name— Kueue's scheduler writes it from thePodSet's name and the CRD defaults it to
main, but the fake client does neither. Eitheromission makes the gate answer
Retryfor an empty or unresolvable card population rather thanfor the units shortage those cases are built to test: still green, exercising nothing. Both now
assert why the workload is held, not only that it is, so the reason cannot drift silently
again.
3. A population case claimed a card was excluded from the partitioned population but asserted
only the verdict state — and an empty profile ledger yields the same state, so the claim
went unpinned. This change makes it worse by adding coverage as a third route to that state, so
the case now asserts the message. Verified by mutation: forcing
servesFamily(Partitioned)truenow fails it on the message; before, it passed.
4. A partition case named for two cards gave the first seven free placements, so both requested
instances came from it and the second was never examined. One placement each now forces the
traversal — the loop this change added a per-card exclusion to.
assignedFlavorrests on needs no guard from this PR — it is already enforced.It resolves a PodSet's card population from the single flavor its assignment carries, which holds
while a ClusterQueue covers exactly one resource. An earlier revision added a unit guard in
node_queue_test.go; review pointed out it duplicatedTestNodeQueueReconciler_FillsAndSortsByCount, which already asserts one resource group and onecovered resource, for both a CPU-only and an accelerated queue, through the full reconcile
path. Checked rather than taken on faith: widening an accelerated queue's
CoveredResourcesturns that reconciler case red. So the guard is deleted, and what remains is stated at the
function — including what an admin's own queue breaking the assumption would do: a CPU flavor
pins no accelerator key and covers no card, so the demand is held with
Retry. The choice offlavor is arbitrary there; the direction is not — a flavor that reaches no card can only withhold
capacity, never invent it.
assignedFlavortakes the lexicographically smallest reference when a PodSet is assigned morethan one flavor.
buildResourceGroupsgives an accelerated queue exactly one covered resource,so a PodSet gets exactly one flavor; the tie-break exists only to keep the verdict stable rather
than to handle a shape this repository can produce. It is documented at the function.
Status.Admissioncan accompany a reservedWorkload. It cannot — Kueue's
SetQuotaReservationstores the admission and then unconditionallydereferences
admission.ClusterQueue, so a nil one panics inside Kueue. The code is fail-safethere regardless, and the comment records why the state is unreachable rather than merely that
it is checked.
accelerator key off a ResourceFlavor's
nodeLabelswas a private copy ofnodefeature.ExtractAcceleratableNodeKeys' predicate, and that copy had already drifted once inthis PR's own review history (it read a period as a metadata suffix, discarding a legal group id).
It is now
nodefeature.ExtractAcceleratableKeys, a labels-map extractor next to the labelswriter, called from both places. Mutating the predicate turns tests red in both packages,
which is what proves the controller reads the shared one rather than a stale copy.
applyVerdict'sno-op guard never worked. It compared the verdict against the object Kueue's
PatchStatushands an update function, and
PatchStatusreplaces that object with a bare server-side-applyskeleton carrying no status at all — so the check was never found, the guard passed everything
through, and every reconcile re-applied. An apply of identical content is a no-op on the API
server, so nothing was ever visible. It was found by mutating the fix and watching the mutation
survive: the review argued the guard skips a write and pins a stale message, which is what it
was written to do and not what it did. The comparison now reads the fetched Workload, and it
includes the message — the two belong together, because a working guard comparing only the state
would pin the first cause onto the Workload for as long as the state held, now that a verdict
names its cause and its role.
use and never gives one back — exact while the populations are disjoint or nested the way this
operator's flavors make them (a card carries one model; one model's flavors differ by node batch).
Where an admin's selectors overlap asymmetrically, a broad demand can take the card a narrow one
also needed. Not fixed by a matching, for one reason: no ordering can flip the direction of the
error. A demand only ever takes cards it may itself use, so the fit can report a shortage that
is not there — never room that is not there.
credits, which is not a resource namethis operator writes. Kueue accounts the manufacturer's credits resource (the chart's
transformation replaces the raw accelerator keys with it), so every one of those fixtures
exercised a fallback path instead of the lookup production takes. They now use the real name
through
nodefeature, and the shorthand is pinned as not a credits resource in a new tabletest, so the next fixture cannot repeat it silently. Same shape as the missing
PodSetAssignment.Nameabove: a fixture that is not what production produces answers adifferent question, in green.
smallest of them. This arrived as a test-determinism complaint, and the test was the symptom: the
choice was arbitrary. Such a flavor covers genuinely ambiguous cards, so scoping a role to one
of them is a guess, and the answer that cannot be wrong is to cover nothing and hold the demand.
It also makes the property testable, which no arrangement of the previous test could — every draw
over an ambiguous flavor returns the same empty string, so one pass is a verdict instead of a coin
flip.
make lintclean,go vetclean,go test ./pkg/worker/...and-racegreen.Does this PR introduce a user-facing change?