From 8abe87b625686547bc68f9e3c3798cd2cb743515 Mon Sep 17 00:00:00 2001 From: dawn <93917549+dawNotPoi@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:46:20 +0800 Subject: [PATCH] fix(outbounds): preserve stable subscription tags (#6345) An inserted link could claim a previous positional tag before the existing identity that owned it was processed. The owner was then suffixed and the swapped mapping persisted across refreshes. Reserve tags for identities still present in the batch so positional fallback, fresh allocation, and collision suffixes cannot take them. --- internal/web/service/outbound_subscription.go | 13 +++++- .../web/service/outbound_subscription_test.go | 44 ++++++++++++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/internal/web/service/outbound_subscription.go b/internal/web/service/outbound_subscription.go index b0a5dee8b..9e1972a99 100644 --- a/internal/web/service/outbound_subscription.go +++ b/internal/web/service/outbound_subscription.go @@ -462,6 +462,13 @@ func (s *OutboundSubscriptionService) recordError(sub *model.OutboundSubscriptio // written back into parsed[i]["tag"]. The returned slice holds the assigned tags // in order. When tagPrefix is empty a "sub-" prefix is used for fresh tags. func assignStableTags(parsed []link.Outbound, identities []string, prev map[string]string, prevTagByIndex map[int]string, subID int, tagPrefix string) []string { + reservedStableTags := map[string]bool{} + for i := range parsed { + if i < len(identities) && prev[identities[i]] != "" { + reservedStableTags[prev[identities[i]]] = true + } + } + used := map[string]bool{} // uniqueness within this refresh batch assigned := make([]string, len(parsed)) for i := range parsed { @@ -470,12 +477,14 @@ func assignStableTags(parsed []link.Outbound, identities []string, prev map[stri id = identities[i] } candidate := "" + identityTag := "" if old, ok := prev[id]; ok && old != "" { candidate = old + identityTag = old } if candidate == "" { // try to reuse by rough positional match from previous fetch (best effort) - if old, ok := prevTagByIndex[i]; ok && old != "" { + if old, ok := prevTagByIndex[i]; ok && old != "" && !reservedStableTags[old] { candidate = old } } @@ -493,7 +502,7 @@ func assignStableTags(parsed []link.Outbound, identities []string, prev map[stri } // ensure local uniqueness inside this batch final := candidate - for k := 1; used[final]; k++ { + for k := 1; used[final] || (reservedStableTags[final] && final != identityTag); k++ { final = fmt.Sprintf("%s-%d", candidate, k) } used[final] = true diff --git a/internal/web/service/outbound_subscription_test.go b/internal/web/service/outbound_subscription_test.go index 658b822e2..279166218 100644 --- a/internal/web/service/outbound_subscription_test.go +++ b/internal/web/service/outbound_subscription_test.go @@ -3,6 +3,7 @@ package service import ( "bytes" "errors" + "slices" "testing" "gorm.io/gorm" @@ -166,12 +167,53 @@ func TestAssignStableTags(t *testing.T) { t.Run("falls back to the previous tag at the same position", func(t *testing.T) { parsed := []link.Outbound{{"tag": "JP-Tokyo"}} - got := assignStableTags(parsed, []string{"id-new"}, map[string]string{}, map[int]string{0: "sub1-oldpos"}, 1, "") + prev := map[string]string{"id-gone": "sub1-oldpos"} + got := assignStableTags(parsed, []string{"id-new"}, prev, map[int]string{0: "sub1-oldpos"}, 1, "") if got[0] != "sub1-oldpos" { t.Fatalf("got %q, want sub1-oldpos", got[0]) } }) + t.Run("does not let an inserted link steal a stable tag", func(t *testing.T) { + parsed := []link.Outbound{{"tag": "Poland"}, {"tag": "NewServer"}, {"tag": "Netherlands"}} + prev := map[string]string{ + "id-poland": "sub1-poland", + "id-netherlands": "sub1-netherlands", + } + prevTagByIndex := map[int]string{0: "sub1-poland", 1: "sub1-netherlands"} + + got := assignStableTags(parsed, []string{"id-poland", "id-new", "id-netherlands"}, prev, prevTagByIndex, 1, "") + want := []string{"sub1-poland", "sub1-newserver", "sub1-netherlands"} + if !slices.Equal(got, want) { + t.Fatalf("got %v, want %v", got, want) + } + }) + + t.Run("does not let a fresh tag steal a stable tag", func(t *testing.T) { + parsed := []link.Outbound{{"tag": "Netherlands"}, {"tag": "Renamed"}} + prev := map[string]string{"id-netherlands": "sub1-netherlands"} + + got := assignStableTags(parsed, []string{"id-new", "id-netherlands"}, prev, nil, 1, "") + want := []string{"sub1-netherlands-1", "sub1-netherlands"} + if !slices.Equal(got, want) { + t.Fatalf("got %v, want %v", got, want) + } + }) + + t.Run("skips reserved tags while adding a suffix", func(t *testing.T) { + parsed := []link.Outbound{{"tag": "Netherlands"}, {"tag": "First"}, {"tag": "Second"}} + prev := map[string]string{ + "id-first": "sub1-netherlands", + "id-second": "sub1-netherlands-1", + } + + got := assignStableTags(parsed, []string{"id-new", "id-first", "id-second"}, prev, nil, 1, "") + want := []string{"sub1-netherlands-2", "sub1-netherlands", "sub1-netherlands-1"} + if !slices.Equal(got, want) { + t.Fatalf("got %v, want %v", got, want) + } + }) + t.Run("allocates a fresh tag with the default sub- prefix", func(t *testing.T) { parsed := []link.Outbound{{"tag": "Tokyo"}} got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 7, "")