Reject shared-node PBS ties despite learned source

A globally observed second cluster can leave a PBS source apparently mapped to the first while both clusters match a shared-node snapshot. Do not promote a positive but tied guest match into the VMID-only inference path. Regression covers both VM and CT completed and in-flight snapshots with the second cluster visible on another PBS instance.

Contract-Neutral: Preserve existing PBS backup attribution safety without changing API fields, persisted shapes, or source contracts.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-28 04:32:02 +01:00
parent 7d7b1a0b85
commit d704afa68a
2 changed files with 28 additions and 10 deletions
+24 -4
View File
@@ -98,11 +98,15 @@ func TestSyncGuestBackupTimesNewestPBSBackupPreservesCollisionGuard(t *testing.T
got := make(map[string]time.Time)
if backupType == "vm" {
for _, guest := range snapshot.VMs {
got[guest.Instance] = guest.LastBackup
if guest.VMID == 112 {
got[guest.Instance] = guest.LastBackup
}
}
} else {
for _, guest := range snapshot.Containers {
got[guest.Instance] = guest.LastBackup
if guest.VMID == 112 {
got[guest.Instance] = guest.LastBackup
}
}
}
if !got["cluster-a"].Equal(recentA) {
@@ -132,11 +136,13 @@ func TestSyncGuestBackupTimesNewestPBSBackupSameNodeCollision(t *testing.T) {
state.UpdateVMs([]VM{
{VMID: 112, Name: "guest-a", Instance: "cluster-a", Node: "pve"},
{VMID: 112, Name: "guest-b", Instance: "cluster-b", Node: "pve"},
{VMID: 113, Name: "other-b", Instance: "cluster-b", Node: "pve"},
})
} else {
state.UpdateContainers([]Container{
{VMID: 112, Name: "guest-a", Instance: "cluster-a", Node: "pve"},
{VMID: 112, Name: "guest-b", Instance: "cluster-b", Node: "pve"},
{VMID: 113, Name: "other-b", Instance: "cluster-b", Node: "pve"},
})
}
@@ -150,6 +156,10 @@ func TestSyncGuestBackupTimesNewestPBSBackupSameNodeCollision(t *testing.T) {
// Both guests match node pve, but guest-b is the stronger match.
{ID: "recent-b", VMID: "112", BackupType: backupType, BackupTime: recentB,
Instance: "pbs-main", Namespace: "pve", Comment: "guest-b"},
// B is visible on a different PBS instance. That is not proof
// that pbs-main's tied snapshot belongs to A rather than B.
{ID: "other-b", VMID: "113", BackupType: backupType, BackupTime: recentB,
Instance: "pbs-other"},
// Neither guest owns an otherwise indistinguishable newer copy.
{ID: "shared-node", VMID: "112", BackupType: backupType, BackupTime: now.Add(-time.Hour),
Instance: "pbs-main", Namespace: "pve"},
@@ -167,11 +177,15 @@ func TestSyncGuestBackupTimesNewestPBSBackupSameNodeCollision(t *testing.T) {
got := make(map[string]time.Time)
if backupType == "vm" {
for _, guest := range snapshot.VMs {
got[guest.Instance] = guest.LastBackup
if guest.VMID == 112 {
got[guest.Instance] = guest.LastBackup
}
}
} else {
for _, guest := range snapshot.Containers {
got[guest.Instance] = guest.LastBackup
if guest.VMID == 112 {
got[guest.Instance] = guest.LastBackup
}
}
}
if !got["cluster-a"].Equal(recentA) {
@@ -182,12 +196,18 @@ func TestSyncGuestBackupTimesNewestPBSBackupSameNodeCollision(t *testing.T) {
}
if backupType == "vm" {
for _, guest := range snapshot.VMs {
if guest.VMID != 112 {
continue
}
if guest.BackupInProgress != (guest.Instance == "cluster-b") {
t.Errorf("%s BackupInProgress = %v, want only cluster-b running", guest.Instance, guest.BackupInProgress)
}
}
} else {
for _, guest := range snapshot.Containers {
if guest.VMID != 112 {
continue
}
if guest.BackupInProgress != (guest.Instance == "cluster-b") {
t.Errorf("%s BackupInProgress = %v, want only cluster-b running", guest.Instance, guest.BackupInProgress)
}
+4 -6
View File
@@ -4745,8 +4745,9 @@ func (s *State) SyncGuestBackupTimes() {
// A namespace such as "pve" can match two clusters with the
// same node label. A positive score is not attribution when a
// different connection matches the same snapshot at least as
// strongly. A stronger competitor rules this guest out; a tie
// needs independent storage/source evidence below (#2292).
// strongly. Do not use batch-learned source inference to break a
// positive tie: it can see the other connection on another PBS
// instance without proving which one wrote this snapshot (#2292).
strongestOther := 0
for _, other := range subjectGuests[subjectKey] {
if other.instance == instance {
@@ -4760,12 +4761,9 @@ func (s *State) SyncGuestBackupTimes() {
strongestOther = otherScore
}
}
if strongestOther > score {
if strongestOther >= score {
continue
}
if strongestOther == score {
score = 0
}
}
if score == 0 {
if !subjectIsAmbiguous[subjectKey] {