Skip to content

fix(collector): emit lossless label/taint readings - #2093

Draft
varmesh wants to merge 7 commits into
mainfrom
fix/topology-label-taint-key-collision
Draft

fix(collector): emit lossless label/taint readings#2093
varmesh wants to merge 7 commits into
mainfrom
fix/topology-label-taint-key-collision

Conversation

@varmesh

@varmesh varmesh commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

The topology collector encodes a label's key and value into a single snapshot key, so two different readings can produce the same key and one is silently dropped. Adds an items encoding that keeps each reading's fields separate; the existing data map is unchanged.

Motivation / Context

encodeLabels renders a multi-valued label as <key>.<value>, which collides with a label literally named that. encodeTaints has a second defect: it counts entries per key but disambiguates with effect, so two taints sharing both collapse into one entry.

items carries key, value, effect and node membership as separate fields. Nothing is manufactured, so nothing can collide.

Fixes: #2003
Related: N/A

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional changes)
  • Build/CI/tooling

Component(s) Affected

  • CLI (cmd/aicr, pkg/cli)
  • API server (cmd/aicrd, pkg/server)
  • Recipe engine / data (pkg/recipe)
  • Bundlers (pkg/bundler, pkg/component/*)
  • Collectors / snapshotter (pkg/collector, pkg/snapshotter)
  • Validator (pkg/validator)
  • Core libraries (pkg/errors, pkg/k8s)
  • Docs/examples (docs/, examples/)
  • Other: pkg/constraints, pkg/fingerprint, pkg/client/v1, pkg/evidence/redact

Implementation Notes

Additive, not a migration. data is byte-identical to before — verified by capturing the same cluster with the released binary and this build and diffing both subtypes. Consumers that are not migrated cannot regress.

No apiVersion bump. Adding an optional field beside an existing one is additive-only under ADR-011 matching how da21e227 introduced Subtype.Items.

One decode path. topology.LabelReadings / TaintReadings prefer items and fall back to data, so the branch exists once rather than in each consumer. pkg/constraints, pkg/fingerprint, pkg/snapshotter, pkg/client/v1 and the
shipped template all read through it. Every legacy path is untouched and still exercised by tests, so pre-existing snapshots evaluate exactly as they did.

Testing

make qualify        # exit 0, all six stages

Also verified against a live 3-node Kind cluster: data output byte-identical between the released binary and this build, items present and correct, and --max-nodes-per-entry still capping the rendered list.

New coverage includes collision determinism (50 and 200 iterations, since the failure being guarded is a race on map ordering), legacy data-only compatibility, and the first rendering test for the shipped snapshot template.

Risk Assessment

  • Low — Isolated change, well-tested, easy to revert
  • Medium — Touches multiple components or has broader impact
  • High — Breaking change, affects critical paths, or complex rollout

Checklist

  • Tests pass locally (make test with -race)
  • Linter passes (make lint)
  • I did not skip/disable tests to make CI green
  • I added/updated tests for new functionality
  • I updated docs if user-facing behavior changed
  • Changes follow existing patterns in the codebase
  • Commits are cryptographically signed (git commit -S) —

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Recipe evidence check

No leaf overlays affected by this PR.

This gate is warning-only and never blocks merge.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Node topology collection now emits lossless label and taint items with explicit context, node membership, counts, and truncation metadata. New accessors decode item-based readings and legacy folded maps. Templates, GPU consumers, fingerprint extraction, snapshot comparison, and redaction support both representations. Documentation and tests cover collisions, malformed data, ordering, truncation, and compatibility.

Estimated code review effort: 4 (Complex) | ~60 minutes

Suggested reviewers: njhensley

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning Most changes support the new topology encoding, but the registry allowlist copyright header is unrelated to issue #2003. Remove the unrelated copyright-only change to tools/registry-inventory/registry-allowlist.yaml, or link it to a separate issue.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: emitting lossless label and taint readings from the collector.
Description check ✅ Passed The description directly explains the collision bug, the additive items encoding, compatibility behavior, and testing.
Linked Issues check ✅ Passed The changes implement issue #2003 by preserving label and taint fields structurally and preventing collision-related data loss.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/topology-label-taint-key-collision

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/client/v1/gpu_driver_state.go`:
- Around line 420-424: Update hasMultipleValues to use exact-key matching for
node.kubernetes.io/instance-type while preserving child-key prefix matching for
nvidia.com/gpu.*. Add a lossless regression case covering multiple values under
an instance-type.* label without the exact instance-type key, ensuring it does
not report a heterogeneous GPU pool.

In `@pkg/collector/topology/readings.go`:
- Around line 200-214: Update itemNodes to require node-count and truncated
metadata for Items, validating both types and rejecting malformed values instead
of applying legacy defaults. Ensure node-count is nonnegative and at least the
visible node count, and require truncated to agree with
IsTruncatedNodeList(list), including rejecting false when the rendered suffix
indicates truncation. Add regression coverage for each invalid or inconsistent
metadata state.
- Around line 149-152: Update taintReadingsFromItems to require a non-empty
context.effect and validate it against the supported taint effects before
appending each TaintReading; return an error for missing, empty, or unsupported
values. Add malformed-item tests covering missing/empty effects and invalid
effect values.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 40b760c7-2117-46f6-bbba-799f48b7cccd

📥 Commits

Reviewing files that changed from the base of the PR and between 90ef73c and da41c8c.

📒 Files selected for processing (19)
  • .agents/skills/aicr-analyzing-snapshots/SKILL.md
  • docs/contributor/collector.md
  • docs/integrator/data-flow.md
  • docs/integrator/measurement-api.md
  • examples/templates/snapshot-template.md.tmpl
  • pkg/client/v1/gpu_driver_state.go
  • pkg/client/v1/gpu_driver_state_test.go
  • pkg/collector/topology/readings.go
  • pkg/collector/topology/readings_test.go
  • pkg/collector/topology/template_test.go
  • pkg/collector/topology/topology.go
  • pkg/collector/topology/topology_test.go
  • pkg/constraints/gpu_nodes.go
  • pkg/constraints/gpu_nodes_test.go
  • pkg/evidence/redact/redact_test.go
  • pkg/fingerprint/from_measurements.go
  • pkg/fingerprint/from_measurements_test.go
  • pkg/snapshotter/snapshot.go
  • pkg/snapshotter/snapshot_test.go

Comment thread pkg/client/v1/gpu_driver_state.go Outdated
Comment thread pkg/collector/topology/readings.go Outdated
Comment thread pkg/collector/topology/readings.go Outdated
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report ✅

Metric Value
Coverage 82.5%
Threshold 80%
Status Pass
Coverage Badge
![Coverage](https://img.shields.io/badge/coverage-82.5%25-brightgreen)

Merging this branch changes the coverage (3 decrease, 3 increase)

Impacted Packages Coverage Δ 🤖
github.com/NVIDIA/aicr/pkg/client/v1 83.15% (+0.16%) 👍
github.com/NVIDIA/aicr/pkg/collector/topology 93.97% (+9.56%) 👍
github.com/NVIDIA/aicr/pkg/constraints 97.95% (-0.14%) 👎
github.com/NVIDIA/aicr/pkg/diff 96.20% (-0.77%) 👎
github.com/NVIDIA/aicr/pkg/fingerprint 99.15% (+0.21%) 👍
github.com/NVIDIA/aicr/pkg/snapshotter 62.32% (-0.15%) 👎

Coverage by file

Changed files (no unit tests)

Changed File Coverage Δ Total Covered Missed 🤖
github.com/NVIDIA/aicr/pkg/client/v1/gpu_driver_state.go 92.94% (-0.35%) 170 (+21) 158 (+19) 12 (+2) 👎
github.com/NVIDIA/aicr/pkg/collector/topology/readings.go 94.91% (+94.91%) 216 (+216) 205 (+205) 11 (+11) 🌟
github.com/NVIDIA/aicr/pkg/collector/topology/topology.go 92.62% (+8.20%) 149 (+72) 138 (+73) 11 (-1) 👍
github.com/NVIDIA/aicr/pkg/constraints/gpu_nodes.go 97.89% (-0.37%) 142 (+27) 139 (+26) 3 (+1) 👎
github.com/NVIDIA/aicr/pkg/diff/diff.go 100.00% (ø) 161 (+1) 161 (+1) 0
github.com/NVIDIA/aicr/pkg/diff/topology.go 93.75% (+93.75%) 64 (+64) 60 (+60) 4 (+4) 🌟
github.com/NVIDIA/aicr/pkg/fingerprint/from_measurements.go 98.84% (+0.42%) 172 (+46) 170 (+46) 2 👍
github.com/NVIDIA/aicr/pkg/snapshotter/snapshot.go 71.65% (-0.57%) 127 (+1) 91 36 (+1) 👎

Please note that the "Total", "Covered", and "Missed" counts above refer to code statements instead of lines of code. The value in brackets refers to the test coverage of that file in the old version of the code.

@varmesh
varmesh marked this pull request as draft August 6, 2026 11:02

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/collector/topology/readings.go`:
- Around line 206-235: Update the validation in splitNodeList to compare
node-count only with the number of named nodes, excluding the retained
truncation marker from len(nodes), while still returning the marker in Nodes.
Preserve the existing truncated and complete consistency checks, and add a
regression case covering a list such as n1,n2 (+1 more) with node-count 3.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 15b6bb5e-84e9-40bd-8f42-47f2fb6fa575

📥 Commits

Reviewing files that changed from the base of the PR and between da41c8c and 7be2ea3.

📒 Files selected for processing (7)
  • docs/integrator/measurement-api.md
  • pkg/client/v1/gpu_driver_state.go
  • pkg/client/v1/gpu_driver_state_test.go
  • pkg/collector/topology/readings.go
  • pkg/collector/topology/readings_test.go
  • pkg/evidence/redact/redact_test.go
  • pkg/fingerprint/from_measurements_test.go

Comment thread pkg/collector/topology/readings.go Outdated
@varmesh
varmesh marked this pull request as ready for review August 6, 2026 13:23

@mchmarny mchmarny left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Requesting changes: 2 correctness blockers in the lossless topology Items path. I reviewed exact head 7be2ea3d0b70ba8b9cb6988352a488d0325e1d35 across collector encoding/decoding, the GPU-node constraint, fingerprint consumers, the shipped template, the legacy fallback, and live CI. Checks are green, but the inline issues can accept contradictory or internally inconsistent topology data.

Coverage passes overall at 82.2%; the reported package deltas for pkg/constraints (-0.77%) and pkg/fingerprint (-1.93%) also exceed the repository 0.5% flag threshold.

"regenerate the snapshot with a current aicr build", r.RawKey, r.Nodes),
map[string]any{ctxConstraint: GPUNodesLabelConstraintName, ctxReading: r.RawKey})
}
entries = append(entries, labelNodeSet{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking: this Items path never enforces the Kubernetes invariant that a node has only one value for a given label key. A structurally valid snapshot can list gpu-a under both key=true and key=false; evaluateEveryGPUNodeHasValue ignores the false entry and returns PASS because the true entry covers the universe. The legacy Data path rejects overlapping value sets, so making Items authoritative weakens the failure direction. Reject duplicate node membership within and across readings for the same key, and add a regression asserting ErrCodeInvalidRequest for conflicting Items.

Comment thread pkg/collector/topology/readings.go Outdated
fmt.Sprintf("topology item %d: %q is %v but %q %s the truncation marker",
idx, itemDataTruncated, truncated, itemDataNodeList,
map[bool]string{true: "carries", false: "does not carry"}[marker]))
case truncated && count <= len(nodes):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking: truncated Items only require node-count > len(nodes), so node-list: "n1,n2 (+3 more)", node-count: 3, truncated: true is accepted even though the suffix implies five nodes. countGPUNodesFromItems then records 3 while the rendered list says 5, violating the documented true-total contract. Parse the +N suffix and require node-count == visible names + N; cover both lower and higher mismatches.

@yuanchen8911 yuanchen8911 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.

The lossless items encoding is well built where it counts: the legacy taint/label Data encoders are unchanged (non-collision output verified byte-identical; summary counts now derive from items, so collision-cluster summaries correct their previous undercount), the shared topology.LabelReadings/TaintReadings decoder keeps the fallback branch in one place for Go consumers (the shipped template implements its own fallback), and the collision-determinism and legacy-compat coverage is thorough. Verified at 7be2ea3, CI green.

There are two blocking issues, both at the boundaries rather than in the core logic.

First, the upgrade path breaks the documented drift gate: a baseline captured before this release has no items, pkg/diff compares items unconditionally, and aicr diff --fail-on-drift then exits with a conflict on an unchanged cluster — hundreds of false adds. For the label/taint subtypes, treat items as authoritative and skip data when both snapshots carry items; otherwise compare data and suppress the one-sided items representation. Mixed-vintage comparisons must also normalize the representation-sensitive summary label/taint counts, which otherwise still trip the gate on collision clusters. This removes upgrade-only drift; when both snapshots carry items, ignoring folded data also stops collision flapping, while legacy-vs-legacy comparisons retain the pre-existing limitation.

Second, emitting the full node list in both data and items roughly doubles the topology payload; with the default --max-nodes-per-entry=0 and agent mode's mandatory ConfigMap handoff (plus the reader's own 1 MiB cap), a large-cluster snapshot that fit before this change can now fail at capture — an independent 100-node/150-label probe measured the topology YAML alone growing from ~548 KB to ~1.12 MB.

Two smaller boundary-validation issues follow the repo's fail-closed rule for externally produced snapshots (measurement-api.md is a documented integrator contract): the items decoder accepts a node listed under two values of the same label key, where the legacy decoder rejects overlapping node sets; and the truncation check never parses the "(+N more)" count, so an item whose node-count contradicts its own marker is accepted.

Two additional consistency gaps affect the legacy fallback: NodeCount is not the documented true total when truncated, and legacy taint decoding leaves the synthesized .Effect suffix attached to the logical Key.

The rest is test-depth and rendering polish, inline.

).
WithSubtype(measurement.Subtype{Name: "taint", Data: taintData}).
WithSubtype(measurement.Subtype{Name: "label", Data: labelData}).
WithSubtype(measurement.Subtype{Name: "taint", Data: taintData, Items: taintItems}).

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.

Emitting items makes every pre-upgrade baseline report drift against a post-upgrade snapshot of the same unchanged cluster. compareItems (pkg/diff/diff.go:220) runs unconditionally, so a nil-items baseline yields an items.length change plus one added-change per item field, and aicr diff --fail-on-drift returns a conflict — the exact CI pattern documented in docs/integrator/automation.md.

Suggested rule for pkg/diff: for the label/taint subtypes, treat items as authoritative and skip data when both snapshots carry items; otherwise compare data and suppress the one-sided items representation. Mixed-vintage comparisons must also normalize the representation-sensitive summary label/taint counts — a pre-upgrade collision snapshot has undercounted summary.label-count/taint-count, so the corrected counts alone would still trip the gate. This removes upgrade-only drift; ignoring folded data when both sides carry items also stops same-version collision flapping (encodeTaints/encodeLabels iterate unordered maps with last-writer-wins on folded-key collisions), while legacy-vs-legacy comparisons retain the pre-existing limitation.

WithSubtype(measurement.Subtype{Name: "taint", Data: taintData}).
WithSubtype(measurement.Subtype{Name: "label", Data: labelData}).
WithSubtype(measurement.Subtype{Name: "taint", Data: taintData, Items: taintItems}).
WithSubtype(measurement.Subtype{Name: "label", Data: labelData, Items: labelItems}).

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.

The items entries duplicate the full node-list strings already carried in data, roughly doubling the topology subtype. With the default --max-nodes-per-entry=0 this is unbounded, agent mode always hands the snapshot through a ConfigMap (pkg/snapshotter/agent.go:660), and the ConfigMap read path caps at 1 MiB (pkg/serializer/reader.go:719). A 100-node/150-label probe measured the topology YAML growing from 548,124 to 1,115,885 bytes — past the limit before any other measurement is included. Snapshots that fit before this change can now fail at the internal handoff; the payload needs to stay bounded (e.g. don't carry the same membership strings twice).

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.

I agree with items cross-referencing data for v1alpha2.

Slimming data is not safe: pre-PR binaries read it directly. Their node-set evaluator rejects truncated lists, while fingerprinting does not recover the (+N more) count. An older same-version consumer could therefore fail validation or undercount GPU nodes. ADR-011 requires the existing data encoding and semantics to remain unchanged.

For the cross-reference:

  • Omit node-list only when the folded key uniquely identifies one item; collisions keep the inline list.
  • Use an explicit reference to the data entry. An omitted node-list without that reference must fail decoding.
  • Centralize hydration and validation in LabelReadings and TaintReadings, checking uniqueness, identity, count, and truncation.
  • In pkg/diff, compare hydrated items when both snapshots have them; retain data normalization for mixed-vintage comparisons.

Agreed on removing data in the next snapshot apiVersion. New emissions can then use self-contained items, while the v1alpha2 compatibility decoder remains through its acceptance window. Worth documenting that transition in measurement-api.md.

"regenerate the snapshot with a current aicr build", r.RawKey, r.Nodes),
map[string]any{ctxConstraint: GPUNodesLabelConstraintName, ctxReading: r.RawKey})
}
entries = append(entries, labelNodeSet{

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.

decodeLabelEntriesFromItems appends every key-matching entry with no overlap check, while decodeLabelEntriesFromData requires accepted entries to partition their nodes (fail-closed, per its own comment). An externally produced snapshot listing one GPU node under both value=true and value=false lets evaluateEveryGPUNodeHasValue pass on the true entry alone. Since measurement-api.md is a documented producer contract, this ambiguous shape should return ErrCodeInvalidRequest like the legacy path — the overlap check ports over cheaply.

Comment thread pkg/collector/topology/readings.go Outdated
// taintReadingsFromData decodes the legacy taint encoding, whose two shapes
// are told apart by field count: "<effect>|<value>|<nodes>" for a plain key,
// "<value>|<nodes>" for a disambiguated one where the effect is the key suffix.
func taintReadingsFromData(data map[string]measurement.Reading) []TaintReading {

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.

In the two-field legacy fallback, taintReadingsFromData extracts Effect from the key.Effect suffix but leaves the suffix attached to Key, so legacy decoding returns Key "node.kubernetes.io/unreachable.NoExecute" where the items path returns "node.kubernetes.io/unreachable" — the two encodings disagree on the logical key for routine multi-effect taints.

The two-field encoding itself proves the final suffix was synthesized by encodeTaints, and the decoder already relies on that suffix to populate Effect — so it should remove the same suffix from Key, with RawKey preserving the original folded map key. Unlike the label case (where the verbatim-Key limitation is documented and genuinely ambiguous), no closed-set assumption is needed here. No in-repo consumer reads TaintReadings yet, but this is a newly exported contract.

Comment thread pkg/collector/topology/readings.go Outdated
fmt.Sprintf("topology item %d: %q is %v but %q %s the truncation marker",
idx, itemDataTruncated, truncated, itemDataNodeList,
map[bool]string{true: "carries", false: "does not carry"}[marker]))
case truncated && count <= len(nodes):

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.

The truncation consistency check requires count > len(visible) but never parses N from the "(+N more)" marker, so node-list "n1,n2 (+3 more)" with node-count 3 is accepted although the list itself claims 5 total. countGPUNodesFromItems then fingerprints from the inconsistent count. The marker format is fixed — parse N and require count == visible + N.

// Nodes carry Key=Value. When Truncated, the list is incomplete and its
// last element holds the "(+N more)" marker.
Nodes []string
// NodeCount is the true total, including nodes omitted by truncation.

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.

The struct doc says NodeCount is "the true total, including nodes omitted by truncation", but on the data fallback path (readings.go:306, :345) it is len(nodes) from the rendered list — the visible count only. No current consumer reads NodeCount from a legacy-data subtype, so this is latent, but the exported field should not have encoding-dependent semantics: prefer recovering the true total on the fallback path using the same parsed "(+N more)" marker, rather than only documenting that consumers must check Truncated first.

{{- if eq (len $parts) 3 }}
| {{ $key }} | {{ index $parts 0 }} | {{ index $parts 1 }} | {{ index $parts 2 }} |
{{- else }}
| {{ $key }} | | {{ index $parts 0 }} | {{ index $parts 1 }} |

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.

For a legacy data-only snapshot where one taint key carries multiple effects (the routine node.kubernetes.io/unreachable NoExecute/NoSchedule pair), the 2-part branch renders the synthesized "key.Effect" map key in the Taint Key column and leaves Effect blank. This branch is an improvement — the previous template hard-errored on 2-part readings — but the 2-part shape itself proves the trailing suffix is an encoded effect, so split the encoded effect suffix and render both columns correctly.

Comment thread pkg/collector/topology/readings_test.go Outdated
t.Errorf("reading %d = {%q %q n=%d}, want {%q %q n=%d}",
i, got.Key, got.Value, got.NodeCount, w.Key, w.Value, w.NodeCount)
}
if len(got.Nodes) != len(w.Nodes) {

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.

TestLabelReadingsRecoversEveryReading compares only len(got.Nodes) against len(w.Nodes); the exact node names in the want fixtures are never asserted (the taint test does compare contents). A decode bug attributing the right number of wrong nodes would pass.

Comment thread pkg/collector/topology/readings_test.go Outdated
tt.mutate(&item)
st := &measurement.Subtype{Name: "label", Items: []measurement.ItemEntry{item}}
if _, err := LabelReadings(st); err == nil {
t.Error("LabelReadings() error = nil, want a decode error")

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.

The malformed-item cases assert only err != nil, not that the error carries ErrCodeInvalidRequest via stderrors.Is. Callers are insulated today (pkg/constraints re-wraps decoder failures with its own ErrCodeInvalidRequest), but the exported decoder's error-code contract should be pinned by its own tests.


out := render(t)
for _, want := range []string{
"NVIDIA-H100-80GB-HBM3",

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.

The fixture puts NVIDIA-B200 on gpu-b (line 62) but the rendered-output want list only checks NVIDIA-H100-80GB-HBM3 — the second value of the same label key is never asserted, which is the exact multi-value case the items encoding exists for.

varmesh added 5 commits August 7, 2026 09:44
Fixes #2003

Signed-off-by: Varun Ramesh <varamesh@nvidia.com>
Match node.kubernetes.io/instance-type exactly rather than by prefix. It
is a complete label key, not a family, so a distinct label sitting under
its prefix was counted as one of its values and reported a heterogeneous
GPU pool on a uniform cluster. nvidia.com/gpu keeps child matching, which
GFD's label family requires.

Require effect on taint items. A missing field decoded to an empty Effect
that looked valid and would satisfy a caller matching on one. The value is
not checked against the known effects so a snapshot from a newer
Kubernetes stays readable.

Require node-count and truncated, and cross-check all three node fields
against each other: truncated must match the "(+N more)" marker, and
node-count must equal the names in node-list when complete or exceed them
when truncated. Both count directions are rejected -- too low understates
the cluster, too high lets a consumer read a partial list as complete.
The previous code silently ignored a mistyped field and let a declared
truncated:false override a visibly truncated list.

Two test fixtures asserted shapes the collector cannot emit and are
corrected: one rendered a truncated list without the marker, the other
omitted node-count and truncated entirely.

Signed-off-by: Varun Ramesh <varamesh@nvidia.com>
The "(+N more)" suffix states how many names were withheld, so an item's
node-count is checkable exactly rather than by direction. The previous
rule only required the count to exceed the names rendered, accepting
"n1,n2 (+3 more)" with node-count 3 where the list itself states 5;
pkg/fingerprint then reported that count as the cluster's GPU node total.
Parse N and require node-count == names + N, which also subsumes the
complete-list case as N == 0.

Recover the same total on the data fallback. NodeCount is documented as
the pre-truncation total, but the legacy path reported only the names it
could see, so one exported field meant two different things depending on
which encoding a snapshot carried. The marker carries the number on that
path too.

Split the encoded effect out of the folded key when rendering a legacy
taint. Two fields exist only because encodeTaints moved the effect into
the key, so the trailing segment is recoverable rather than ambiguous;
the Taint Key column no longer shows "<key>.<effect>" with Effect blank.

Test hardening: assert the decoder's error code rather than only that an
error occurred, compare node names rather than their count, and assert
both values of the multi-value label the item encoding exists for. Each
previously passed whether or not the behaviour held.

Signed-off-by: Varun Ramesh <varamesh@nvidia.com>
A node carries exactly one value of a given label key, so the readings
for one key must partition their nodes. The item path appended every
key-matching entry without checking, so a snapshot listing gpu-a under
both key=true and key=false decoded cleanly, and
evaluateEveryGPUNodeHasValue skipped the contradicting entry and
returned PASS on a cluster that cannot exist.

The folded path already rejects this, and items win whenever present, so
a working guard had stopped running rather than merely being absent. The
collector cannot emit the shape — it aggregates through a map — but this
change publishes the item format for external producers, and
measurement-api.md is a contract we now have to validate against.

Compared on value rather than reappearance: a node repeated under one
value is redundant, not contradictory, and the folded path accepts it.
Rejecting it would open a fresh divergence between the encodings while
closing one. The rule is labels-only; a node legitimately carries the
same taint key with two effects, so a per-key rule would reject any
NotReady node.

Signed-off-by: Varun Ramesh <varamesh@nvidia.com>
Emitting items makes an aicr upgrade read as cluster drift: a baseline
captured by an older build has no items, compareItems runs
unconditionally, and every field of every item reports as added. The
summary counts disagree too, because this branch sizes them off the
items while older builds sized them off the folded map. Measured on an
unchanged cluster, an upgrade produced 6 changes with no collision and
17 with one, so aicr diff --fail-on-drift fails the morning after an
upgrade.

Reduce both sides to the representation they share before comparing.
When both carry items, drop the folded map: items are authoritative, and
the map is not stable even within one build, since a key collision
resolves by map iteration order. When exactly one carries items, drop
items and restate the summary count over the folded map, which is the
basis the older build used. When neither does, change nothing — there is
no mismatch to reconcile, and restating would mask a corrupted count
rather than report it.

Rollback is the same problem mirrored and is handled by the same rule.
Scoped to NodeTopology: elsewhere a one-sided item list is a real
difference. Inputs are copied, since callers render a snapshot after
comparing it.

Signed-off-by: Varun Ramesh <varamesh@nvidia.com>
@varmesh
varmesh force-pushed the fix/topology-label-taint-key-collision branch from 7be2ea3 to 685a787 Compare August 7, 2026 04:21
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/collector/topology/readings.go`:
- Around line 327-345: Update the legacy two-field decoding branch around the
`strings.LastIndex(key, ".")` logic to set the emitted `TaintReading.Key` to the
prefix before the final dot when extracting the synthesized effect, while
preserving the original key in `RawKey`. Add a Data-only regression test
covering one taint key with two effects and verifying the logical key, raw key,
and effects match the Items path.

In `@pkg/collector/topology/topology.go`:
- Around line 123-136: Update the topology measurement construction around the
TypeNodeTopology subtypes so node membership is not emitted unboundedly in both
Data and Items when MaxNodesPerEntry is zero. Apply a snapshot-safe cap to the
duplicated payload or remove the redundant membership copy, while preserving the
required compatibility path and existing count semantics.
- Around line 314-319: Update truncatedNodeListRemainder to return an error when
strconv.Atoi cannot parse the matched suffix, including integer overflow,
instead of returning (0, false). Propagate that error through itemNodes so
malformed matched suffixes fail closed, and add a regression test covering an
overflowing remainder such as “+9223372036854775808 more”.

In `@pkg/constraints/gpu_nodes.go`:
- Around line 213-220: The comment in the truncated-reading branch should state
that truncated readings are rejected because node-set constraints require a
complete node list, not that token validation is skipped. Update the comment
directly above the `if r.Truncated` check and leave the existing
`ErrCodeInvalidRequest` return behavior unchanged.

In `@pkg/diff/topology.go`:
- Around line 72-73: Update the mixed-vintage branch around baseHas != targetHas
in the topology diff logic so independently folded legacy Data maps are not
compared as authoritative values. Normalize legacy entries against structured
Items with collision-aware handling, or exclude ambiguous folded entries,
ensuring unchanged collections cannot report drift when collision winners
differ; add a regression test covering legacy and current snapshots with
different collision winners.

In `@pkg/snapshotter/snapshot.go`:
- Around line 325-330: Update the measurement loop in hasGPUNodesInTopology to
skip nil entries before accessing m.Type, matching the nil guard used by the
equivalent topology loop in gpu_driver_state.go. Preserve the existing filtering
and topology label-reading behavior for non-nil measurements.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: efe03923-0603-4193-94ef-329fa9eb5a8a

📥 Commits

Reviewing files that changed from the base of the PR and between 6ed2486 and 685a787.

📒 Files selected for processing (22)
  • .agents/skills/aicr-analyzing-snapshots/SKILL.md
  • docs/contributor/collector.md
  • docs/integrator/data-flow.md
  • docs/integrator/measurement-api.md
  • examples/templates/snapshot-template.md.tmpl
  • pkg/client/v1/gpu_driver_state.go
  • pkg/client/v1/gpu_driver_state_test.go
  • pkg/collector/topology/readings.go
  • pkg/collector/topology/readings_test.go
  • pkg/collector/topology/template_test.go
  • pkg/collector/topology/topology.go
  • pkg/collector/topology/topology_test.go
  • pkg/constraints/gpu_nodes.go
  • pkg/constraints/gpu_nodes_test.go
  • pkg/diff/diff.go
  • pkg/diff/topology.go
  • pkg/diff/topology_test.go
  • pkg/evidence/redact/redact_test.go
  • pkg/fingerprint/from_measurements.go
  • pkg/fingerprint/from_measurements_test.go
  • pkg/snapshotter/snapshot.go
  • pkg/snapshotter/snapshot_test.go

Comment thread pkg/collector/topology/readings.go Outdated
Comment thread pkg/collector/topology/topology.go
Comment thread pkg/collector/topology/topology.go Outdated
Comment thread pkg/constraints/gpu_nodes.go Outdated
Comment thread pkg/diff/topology.go Outdated
Comment thread pkg/snapshotter/snapshot.go
@varmesh
varmesh marked this pull request as draft August 7, 2026 08:51
Items whose fold key is unambiguous (only one reading maps to it)
carry node-list-ref pointing at the data entry rather than repeating
the node names inline. This keeps items additive without doubling
snapshot size — a 1000-node/40-label probe grows from 602 KB to
610 KB (×1.01) instead of 1.21 MB (×2.01, past the 1 MiB CM cap).

Colliding readings keep their own inline node-list since the shared
data entry describes only one of them unpredictably.

Also in this commit:
- pkg/diff: hydrate references before comparing items; exclude
  collision-ambiguous keys from mixed-vintage folded-map comparison
- pkg/snapshotter: nil guard on hasGPUData measurement loop
- HydrateItems: defensive length guard against readings/items mismatch
- docs/measurement-api.md: fix label-count in worked example (1→3)
- skill file: document node-list-ref alongside node-list

Signed-off-by: Varun Ramesh <varamesh@nvidia.com>

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/integrator/measurement-api.md (1)

303-308: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Correct the legacy data description.

The statement that the taint and label subtypes carry every reading twice is incorrect. The legacy data map is lossy when folded keys collide. The example has three items entries but only two data keys. State that items contains every reading and that data is a compatibility representation that can drop collisions.

Proposed wording
-The `taint` and `label` subtypes carry every reading twice — as `items` (lossless)
-and as the legacy folded `data` map.
+The `taint` and `label` subtypes expose readings as lossless `items`; they also
+retain the legacy folded `data` map, which can drop colliding readings.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/integrator/measurement-api.md` around lines 303 - 308, Update the
NodeTopology shape documentation to state that taint and label subtypes retain
every reading in items, while the legacy data map is a compatibility
representation that may lose readings when folded keys collide. Remove the claim
that both fields contain every reading twice.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/collector/topology/readings_test.go`:
- Around line 573-623: Extend TestReadingsRejectMalformedReference with a
two-item subtype case whose item keys fold onto the same data key while one item
retains itemDataNodeRef, and have it mutate the existing fixture accordingly.
Ensure the case exercises resolveNodeList’s folds[ref] != 1 guard and continues
asserting LabelReadings returns a rejected decode.

In `@pkg/collector/topology/readings.go`:
- Around line 550-568: Document the order contract in both
labelReadingsFromItems and taintReadingsFromItems, stating that each decoder
preserves the original item order so HydrateItems can safely pair nodes[i] with
st.Items[i]. Do not change the existing index-based hydration logic.
- Around line 445-447: Update the default branch of the legacy taint parsing
logic to reject one-field entries by returning an invalid-request error instead
of assigning parts[0] to value. Preserve the existing handling for the
documented two- and three-field encodings and ensure malformed input does not
produce a zero-node reading.

---

Outside diff comments:
In `@docs/integrator/measurement-api.md`:
- Around line 303-308: Update the NodeTopology shape documentation to state that
taint and label subtypes retain every reading in items, while the legacy data
map is a compatibility representation that may lose readings when folded keys
collide. Remove the claim that both fields contain every reading twice.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 5b79b718-cdb6-44ef-bdf8-79a8feaa7074

📥 Commits

Reviewing files that changed from the base of the PR and between 685a787 and 621220e.

📒 Files selected for processing (11)
  • .agents/skills/aicr-analyzing-snapshots/SKILL.md
  • docs/integrator/measurement-api.md
  • examples/templates/snapshot-template.md.tmpl
  • pkg/collector/topology/readings.go
  • pkg/collector/topology/readings_test.go
  • pkg/collector/topology/topology.go
  • pkg/constraints/gpu_nodes.go
  • pkg/diff/topology.go
  • pkg/diff/topology_test.go
  • pkg/snapshotter/snapshot.go
  • tools/registry-inventory/registry-allowlist.yaml

Comment thread pkg/collector/topology/readings_test.go
Comment thread pkg/collector/topology/readings.go
Comment thread pkg/collector/topology/readings.go
- taintReadingsFromData: the 1-field default branch now returns
  ErrCodeInvalidRequest instead of decoding to Effect:"", which the items
  path already forbids. Unreachable from encodeTaints, but a snapshot is
  caller-supplied input.
- TestTaintDataPathRejectsSingleField: covers that branch (was 0%).
- TestReadingsRejectMalformedReference: new case exercising the
  folds[ref]!=1 guard via a 3-item collision subtype where zone=us-west
  and zone.us-west=true both produce rawKey "zone.us-west" (was 0%).
- labelReadingsFromItems / taintReadingsFromItems: document that item
  order is load-bearing — the data path sorts, these must not, because
  HydrateItems pairs readings to items by index.

Coverage: pkg/collector/topology 93.4% -> 94.0%
Signed-off-by: Varun Ramesh <varamesh@nvidia.com>

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/collector/topology/readings.go`:
- Around line 449-451: Reject two-field taints with a suffix-less rawKey by
returning ErrCodeInvalidRequest from the decoding logic in
pkg/collector/topology/readings.go (lines 449-451), while preserving valid two-
and three-field parsing. Add a corresponding two-field suffix-less input and
assert decode rejection in pkg/collector/topology/readings_test.go (lines
453-474).
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 12711ebe-088b-4139-abe5-c6eeee33dd37

📥 Commits

Reviewing files that changed from the base of the PR and between 621220e and 4135d98.

📒 Files selected for processing (2)
  • pkg/collector/topology/readings.go
  • pkg/collector/topology/readings_test.go

Comment on lines +449 to +451
default:
return nil, errors.New(errors.ErrCodeInvalidRequest,
fmt.Sprintf("taint reading %q: expected 2 or 3 pipe-separated fields, got 1", rawKey))

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject legacy two-field taints without an effect suffix.

The two-field format stores Effect in the final rawKey segment. A key without . currently returns a reading with Effect == "".

  • pkg/collector/topology/readings.go#L449-L451: return ErrCodeInvalidRequest when a two-field rawKey has no effect suffix.
  • pkg/collector/topology/readings_test.go#L453-L474: add a two-field input with a suffix-less key and assert decode rejection.
📍 Affects 2 files
  • pkg/collector/topology/readings.go#L449-L451 (this comment)
  • pkg/collector/topology/readings_test.go#L453-L474
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/collector/topology/readings.go` around lines 449 - 451, Reject two-field
taints with a suffix-less rawKey by returning ErrCodeInvalidRequest from the
decoding logic in pkg/collector/topology/readings.go (lines 449-451), while
preserving valid two- and three-field parsing. Add a corresponding two-field
suffix-less input and assert decode rejection in
pkg/collector/topology/readings_test.go (lines 453-474).

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

collector/topology: label/taint disambiguation map keys can collide with real dotted label names, silently dropping readings

3 participants