diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 5fd8e63d1a..872f108a8b 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -7066,10 +7066,11 @@ another pair than the exclusions do, as a relink of the two rows a report-merge left does (a registry now keeps each exclusion's `CreatedAt`); a tie keeps the pair apart. Once a relink rejoins them, the declared link folds one of the rows it names into the other's ID, and `applyManualLinks` -records that ID with the IDs manual links fold (it recognises the node's own -IDs from the joined row's Proxmox facet, and the agent's source-specific -ID), so pin succession keeps the link instead of re-keying it onto the -joined ID and handing the decision back to the older exclusions. The paths +records that as a `ManualLinkFold` on the joined row (it recognises the +node's own IDs from the row's Proxmox facet, and the agent's source-specific +ID), so pin succession treats the folded ID as observed and keeps the link +instead of re-keying it onto the joined ID and handing the decision back to +the older exclusions, and report-merge of the joined row names that pair. The paths that join rows after ingest apply the same decision: `applyManualLinks` skips a link between a node row and an agent row split after it, and the presentation filter (`ListForPresentation` and the broadcast) keeps such diff --git a/internal/unifiedresources/proxmox_node_links.go b/internal/unifiedresources/proxmox_node_links.go index a290abd831..72cb6bbe72 100644 --- a/internal/unifiedresources/proxmox_node_links.go +++ b/internal/unifiedresources/proxmox_node_links.go @@ -202,22 +202,23 @@ func pinBelongsToAgent(pin ResourceIdentityPin, agent ResourceIdentity) bool { return true } -// joinedPairOwnsIDLocked reports whether id is an ID one side of a joined -// Proxmox node+agent row holds on its own: the node's (derived from the -// row's Proxmox facet, not its merged identity, whose first hostname can be -// the agent's) or the agent's source-specific ID, which an agent reporting -// no machine key holds when split. -func (rr *ResourceRegistry) joinedPairOwnsIDLocked(joined *Resource, id string) bool { +// joinedPairSideLocked reports whether id is an ID one side of a joined +// Proxmox node+agent row holds on its own, and that side's source: the +// node's own IDs (derived from the row's Proxmox facet, not its merged +// identity, whose first hostname can be the agent's) or the agent's +// source-specific ID, which an agent reporting no machine key holds when +// split. +func (rr *ResourceRegistry) joinedPairSideLocked(joined *Resource, id string) (DataSource, bool) { if joined == nil || joined.Proxmox == nil || joined.Agent == nil || CanonicalResourceType(joined.Type) != ResourceTypeAgent { - return false + return "", false } id = CanonicalResourceID(id) if agentID := normalizeSourceID(joined.Agent.AgentID); agentID != "" && id == rr.sourceSpecificID(ResourceTypeAgent, SourceAgent, agentID) { - return true + return SourceAgent, true } sourceID := normalizeSourceID(joined.Proxmox.SourceID) if sourceID == "" { - return false + return "", false } _, identity := resourceFromProxmoxNode(models.Node{ ID: sourceID, @@ -226,7 +227,7 @@ func (rr *ResourceRegistry) joinedPairOwnsIDLocked(joined *Resource, id string) Instance: joined.Proxmox.Instance, ClusterName: joined.Proxmox.ClusterName, }, nil) - return slices.Contains(rr.proxmoxNodeOwnIDs(sourceID, identity), id) + return SourceProxmox, slices.Contains(rr.proxmoxNodeOwnIDs(sourceID, identity), id) } // nodeAgentRowsSplit reports whether two listed rows are a Proxmox node and diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index 4e61ce3275..02afc812ef 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -4950,16 +4950,17 @@ func (rr *ResourceRegistry) applyManualLinks(thresholds map[DataSource]time.Dura otherID = link.ResourceA } other := rr.resources[otherID] - if other == nil && otherID != primaryID && rr.joinedPairOwnsIDLocked(primary, otherID) { - // A relink of a split node and its agent names the two rows, - // and the declared link folds one of them into the other's ID - // once the relink rejoins them. Treat the ID as folded by this - // link so pin succession keeps the link rather than re-keying - // it onto the joined ID, which would let the split decide again. - if rr.linkMergedIDs == nil { - rr.linkMergedIDs = make(map[string]struct{}) + if other == nil && otherID != primaryID { + if source, ok := rr.joinedPairSideLocked(primary, otherID); ok { + // A relink of a split node and its agent names the two rows, + // and the declared link folds one of them into the other's + // ID once the relink rejoins them. Record the fold so pin + // succession keeps the link rather than re-keying it onto + // the joined ID, which would let the split decide again, + // and so report-merge of the joined row names this pair. + recordManualLinkFold(primary, primaryID, &Resource{Sources: []DataSource{source}}, otherID) + rr.indexLinkFoldsLocked(primary) } - rr.linkMergedIDs[otherID] = struct{}{} } if other == nil || otherID == primaryID { continue diff --git a/internal/unifiedresources/registry_merge_policy_test.go b/internal/unifiedresources/registry_merge_policy_test.go index 16ac30efd7..c43930d1a8 100644 --- a/internal/unifiedresources/registry_merge_policy_test.go +++ b/internal/unifiedresources/registry_merge_policy_test.go @@ -350,6 +350,19 @@ func TestOperatorSplitOverridesProxmoxNodeAgentLink(t *testing.T) { t.Fatal(err) } assertViews("after relink", 2, nodeAgentRows{joined: merged.joined}) + // The joined row records the relinked row it took in, so a + // report-merge of it names the relink's own pair. + folded := want.node + if folded == merged.joined { + folded = want.agent + } + recorded := false + for _, fold := range adapter.currentRegistry().ManualLinkFolds(merged.joined) { + recorded = recorded || (fold.HolderID == merged.joined && fold.FoldedID == folded) + } + if !recorded { + t.Fatalf("joined %s records no fold of relinked %s: %+v", merged.joined, folded, adapter.currentRegistry().ManualLinkFolds(merged.joined)) + } // A rebuild that meets the agent first orders the joined row's // hostnames the agent's way; persisting its pins must not // re-key the relink onto the joined ID either.