From d18fcf55ad66ecb34e64138fc23b81da783bccef Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 16 Aug 2026 22:01:55 +0100 Subject: [PATCH] Bucket agent candidates for the child-count parent pass The derived-parent recompute in buildChildCounts may hit the agent fallback scan once per guest, and that scan walked every resource with per-entry string checks, an O(n^2) pass per registry ingest. The pass now buckets agent resources by node name up front and the fallback walks only the matching bucket. Selection is order-independent (best score with smallest-ID tie-break) and scores read live through shared pointers, so results are unchanged; callers outside the pass keep the full scan. Contract-Neutral: performance-only scan bucketing in the parent recompute pass, selection semantics unchanged --- internal/unifiedresources/registry.go | 58 ++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index a541dc82c..96538e5ca 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -125,6 +125,21 @@ type ResourceRegistry struct { // scan over every mapping. Rebuilt with the views; valid only while // viewsDirty is false, like every other cached field above. cachedSourceTargets map[string][]SourceTarget + + // agentNodeScanIndex buckets agent resources by lowercased node name for + // the duration of one buildChildCounts pass, where the agent-parent + // fallback would otherwise scan every resource once per guest. Only that + // pass sets it; nil means callers take the full scan. Bucket membership + // is fixed for the pass (Type and NodeName are never mutated mid-pass) + // while scores stay live through the shared pointers. + agentNodeScanIndex map[string][]agentNodeCandidate +} + +// agentNodeCandidate pairs a resources-map key with its entry so the indexed +// fallback scan sees exactly what the full scan's iteration sees. +type agentNodeCandidate struct { + id string + resource *Resource } // NewRegistry creates a new registry using the provided store for overrides. @@ -4385,27 +4400,30 @@ func (rr *ResourceRegistry) proxmoxNodeParentIDFromResourcesLocked(instance, clu excludeID = CanonicalResourceID(strings.TrimSpace(excludeID)) bestID := "" bestScore := -1 - for id, resource := range rr.resources { + + // Selection is order-independent (best score, smallest ID tie-break), so + // walking a node-name bucket equals walking every resource. + consider := func(id string, resource *Resource) { if resource == nil || resource.Proxmox == nil { - continue + return } // Type and node-name checks run before the ID canonicalization: // in a large estate almost every entry is a guest, and this scan // runs per guest, so per-entry string work here is the difference // between a cheap pass and an O(n^2) allocation storm. if CanonicalResourceType(resource.Type) != ResourceTypeAgent { - continue + return } if !strings.EqualFold(strings.TrimSpace(resource.Proxmox.NodeName), nodeName) { - continue + return } candidateID := CanonicalResourceID(strings.TrimSpace(id)) if candidateID == "" || candidateID == excludeID { - continue + return } score := proxmoxNodeParentScopeScore(instance, clusterName, resource.Proxmox) if score < 0 { - continue + return } score += proxmoxNodeParentAgentScore(resource) if score > bestScore || (score == bestScore && (bestID == "" || candidateID < bestID)) { @@ -4413,6 +4431,18 @@ func (rr *ResourceRegistry) proxmoxNodeParentIDFromResourcesLocked(instance, clu bestScore = score } } + + if rr.agentNodeScanIndex != nil { + // buildChildCounts is mid-pass: only this node's agent candidates + // can match, so skip the full walk. + for _, candidate := range rr.agentNodeScanIndex[strings.ToLower(nodeName)] { + consider(candidate.id, candidate.resource) + } + return bestID + } + for id, resource := range rr.resources { + consider(id, resource) + } return bestID } @@ -4459,6 +4489,22 @@ func proxmoxNodeParentScopeScore(instance, clusterName string, parent *ProxmoxDa } func (rr *ResourceRegistry) buildChildCounts() { + // The parent-resolution loop below may hit the agent fallback scan once + // per guest. Bucketing agents by node name up front turns each of those + // scans into a lookup over that node's few candidates. + rr.agentNodeScanIndex = make(map[string][]agentNodeCandidate) + for id, r := range rr.resources { + if r == nil || r.Proxmox == nil || CanonicalResourceType(r.Type) != ResourceTypeAgent { + continue + } + nodeName := strings.ToLower(strings.TrimSpace(r.Proxmox.NodeName)) + if nodeName == "" { + continue + } + rr.agentNodeScanIndex[nodeName] = append(rr.agentNodeScanIndex[nodeName], agentNodeCandidate{id: id, resource: r}) + } + defer func() { rr.agentNodeScanIndex = nil }() + // ChildCount and ParentName are derived fields. Clear prior values before // recomputing to prevent stale state after re-parenting or parent removal. for _, r := range rr.resources {