fix(topology): canonicalize compute instance ordering - #422
Conversation
Greptile SummaryThis PR fixes non-deterministic compute-instance ordering that originated from Go map iteration being unspecified. It introduces a shared
Confidence Score: 5/5Safe to merge — the changes are purely additive determinism fixes with no behavioral changes on the happy path and thorough permutation tests. Every fan-out boundary is covered, the canonical helper is non-mutating by construction (slices.Clone), the double-sort on the AWS server path is harmless and documented in a comment, and new tests verify exact ordering across input permutations for all affected packages. No data loss, no auth changes, no schema changes. Files Needing Attention: No files require special attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant Client
participant Server as pkg/server/engine.go
participant Engine as Engine (k8s/slinky/slurm)
participant Provider as Provider (aws/oci/dsx)
participant Topo as pkg/topology
Client->>Server: processTopologyRequest(tr)
Server->>Server: getComputeInstances(requested)
alt requested non-empty
Server->>Topo: CanonicalComputeInstances(requested)
else provider implements GetComputeInstances
Server->>Provider: GetComputeInstances(ctx)
Provider-->>Server: raw cis
Server->>Topo: CanonicalComputeInstances(cis)
else
Server->>Engine: GetComputeInstances(ctx, prv)
Note over Engine: k8s/slinky/slurm already return<br/>CanonicalComputeInstances internally
Engine-->>Server: canonical cis
Server->>Topo: CanonicalComputeInstances(cis) [idempotent]
end
Topo-->>Server: canonical []ComputeInstances
Server->>Provider: GenerateTopologyConfig(ctx, cis)
Note over Provider: AWS: CanonicalComputeInstances again + slices.Sorted(maps.Keys)<br/>OCI: slices.Sort(nodes) + sorted instanceIDs<br/>DSX: slices.Sort(nodeIDs)
Provider-->>Server: topology.Graph
Server-->>Client: engine output
Reviews (2): Last reviewed commit: "fix(topology): address maintainer feedba..." | Re-trigger Greptile |
ArangoGutierrez
left a comment
There was a problem hiding this comment.
Thanks for the thorough writeup. The audit in #421 is accurate: every path you list really does derive an ordered slice from an unordered source, and the line references check out. The permutation cases plus the non-mutation assertions in pkg/server/engine_test.go and pkg/topology/request_test.go are the right technique for this class of property; asserting against an exact recorded trace, rather than re-running a map loop and hoping the runtime reorders, is how you actually test it. You also scoped the claim carefully in the PR body instead of asserting that successful runs emit different bytes, which is the version that would not have survived.
I traced each of the five paths to its terminus before writing this.
Paths 1, 2, 3 (slurm aggregateComputeInstances, internal/k8s.GetComputeInstances, slinky getComputeInstances) all feed prv.GenerateTopologyConfig, and for every ClusterTopology-based provider that ends in ToGraph at pkg/topology/graph.go:87. ToGraph inserts each vertex into maps keyed by ID: forest at graph.go:93, nodes[tierKey] at graph.go:98 and graph.go:134-142, treeRoot.Vertices via maps.Copy at graph.go:169-172. No append position survives that.
Path 4, pdsh half. provider_bm.go:36 hands the list to exec.Pdsh, which passes it through cluset.Compact at internal/exec/exec.go:63. Compact sorts prefixes at internal/cluset/cluset.go:54 and numeric suffixes at cluset.go:60-62. The -w argument is canonical before it reaches pdsh, so the sort.Strings you added at request.go:159 is erased one call downstream.
Path 4, IB half. Probe order is genuinely observable: getIbTree at pkg/providers/infiniband/common.go:32 contacts the first unvisited host. But the result lands in rootMap, read back in map order at common.go:58, and NewMerger re-sorts by vertex ID at pkg/topology/merger.go:33-35. A failed probe is klog.Warningf and continue at common.go:35-37, never an error.
Path 5 (AWS InstanceIds). This one does change bytes on the wire. The response is then selected by map lookup at instance_topology.go:87 and appended to ClusterTopology.Instances, terminating in the same ToGraph. Worth noting AWS passes normalize=false at pkg/providers/aws/provider.go:191, so the sort in Normalize (graph.go:218-236) never runs on this path, and the output is stable regardless.
I checked that empirically rather than by reading alone. On main at edb20aa, without this PR, permuting both the region fan-out order and the within-region append order produces a deep-equal *topology.Graph and byte-identical topology.conf under topology/tree and topology/block, at both normalize=false and normalize=true. Since GenerateOutput takes only the graph and its params (pkg/engines/engines.go:31), an identical Graph means identical output for slurm, k8s, nfd, and slinky alike.
That leaves the three claims that survive the byte-identical objection, which are the interesting ones. First-error precedence is real: generateInstanceTopology returns on the first failing region at instance_topology.go:38-41, so with two broken regions the message names an arbitrary one. The IB probe order and the AWS request order are as described above.
So the question I would put back to you, for each of those three: what does an operator see that is different, and what would they do differently as a result? For first-error precedence, the API returns 502 either way, and processRequestWithRetries at pkg/server/engine.go:54-76 retries the whole request (502 is retryable at internal/httpreq/httpreq.go:44) up to maxRetries = 5 at engine.go:36. There is no per-region retry whose behavior depends on which region failed first. For the other two I could not find the symptom at all. I also went looking for the historical cost, since a property like this usually leaves a trail: the only ordering work in this repo is #207 (fix(slurm): fix block order) and #13, both fixed in pkg/translate at the serializer, neither traceable to discovery order. No flaky-test issue, no support issue, no retry logic keyed on order.
On cost, in maintainer terms. The rule here is that added complexity has to buy something a user or operator can observe, and this lands a comparator in pkg/topology, the one package we deliberately do not change casually because every provider and engine depends on its shape. Two specifics. The tie-break half of compareComputeInstances (request.go:65-76, everything after the Region compare) is unreachable through every in-tree producer: slurm keys by region at slurm.go:132, k8s at internal/k8s/utils.go:108 with 122-123, slinky at slinky/engine.go:270 with 290-291. None can emit two entries sharing a Region, so that code is exercised only by its own unit test. And converting ElementsMatch to Equal at slurm_test.go:82 and request_test.go:185 prices in every future ordering change; those assertions were not masking a bug, they were encoding that order is not part of the contract.
There is also a consistency gap worth naming: the change does not fully close the property it is named after. The k8s engine's outbound writes still go out in map order at pkg/engines/k8s/labeler.go:105, and pkg/providers/dsx/instance_topology.go:26-30 and pkg/providers/oci/provider_imds.go:71-74 still build request arguments straight from map iteration. Closing those would roughly double the surface, and by the argument above it would not buy an observable symptom either. (Greptile's note about the second CanonicalComputeInstances at instance_topology.go:40 is correct as well; the slice is already canonical from pkg/server/engine.go:146.)
Where that leaves me: I think the right call for the project is to close #422 and relabel #421 as a feature request we keep on file. If a real reproducibility requirement shows up later (a byte-stable audit trail of outbound API calls, or a replay harness), the audit you wrote is exactly the document we would start from, and it will still be accurate then. That is worth more parked and correct than merged and unexercised. You reached the "feature request, not bug" read yourself in #421; this is the same conclusion applied to the code.
If you want something here with clear value, #386 is open and unassigned and would suit you: engine/provider-gate the main Topograph ClusterRole. Today the api-server holds cluster-wide nodes:update plus pods and daemonsets reads regardless of the configured engine and provider, including combinations that make zero Kubernetes API calls. The issue already carries the per-verb call-site table, an acceptance-criteria list, and the one real design decision (omit the ClusterRole and its binding entirely for zero-rule combos, rather than rendering empty rules), so there is nothing ambiguous left to negotiate. It stacks on #384, which is merged, so you have a worked example of the same gating pattern in the same template. Scope is charts/topograph/templates/rbac.yaml plus helm-unittest cases, and the benefit is one an operator can see in kubectl auth can-i. I will review it promptly.
Say the word and it is yours. Either way, send the second PR.
Signed-off-by: teerth sharma <teerth.2428010112@muj.manipal.edu>
…dering Signed-off-by: teerth sharma <teerth.2428010112@muj.manipal.edu>
843d84d to
f663ebe
Compare
I will be working as told ,I have fixed almost every caveat of this PR , hoping this will provide good reference for the feature request .I have also started working on the issue told thanks for your time |
Description
Closes #421.
Go map iteration is unspecified, but several discovery paths converted maps to ordered provider inputs without sorting. The same logical
ComputeInstancesset could therefore change:InstanceIdsorder;This is observable before final serialization in retries, logs, metrics, probes, and error precedence. It does not claim that every successful run produced different
topology.confbytes.Changes
CanonicalComputeInstancestotal order.CHANGELOG.md.The canonical
topology.Graphshape and provider/engine responsibilities are unchanged.Validation
Fresh on PR head
969fad9after merging currentmain:go veton all 7 affected packages: passgo teston all 7 affected packages: passgit diff --check: passNVIDIA runner workflows are pending the repository's external-contributor validation step. Security validation found a reliability/reproducibility defect, not a security vulnerability: membership, call count, command content, privileges, and exposed data are unchanged.
Checklist
git commit -s).