Skip to content

Commit cf42465

Browse files
kuzzrusclaude
andauthored
fix(outbounds): preserve stable subscription tags (#5)
A newly inserted subscription link could reuse the positional tag of an existing server before that server's own entry was processed later in the same refresh loop. The existing server was then bumped to a suffixed tag instead, silently repointing any routing or balancer rule that referenced the original stable tag at a different physical server. Ported from upstream MHSanaei#6345. Reserves historical tags for identities still present in the current fetch before assigning tags to new/renamed links, so neither the positional-fallback path nor a fresh-tag collision suffix can steal a tag that rightfully belongs to another identity still in the batch. Verified: all 8 TestAssignStableTags subtests pass with the fix; the 3 new ones confirmed to fail without it (reproduces the exact wrong-tag assignment). Reviewed by the repo's automated Claude Bot (verdict: Approve); one doc-comment suggestion applied, one pre-existing orthogonal bug (duplicate identities within a subscription) tracked separately per the bot's own recommendation not to bolt it onto this PR, one upgrade caveat noted in the PR description instead of a code change. Upgrade note: this fix prevents the mis-assignment from recurring, it does not repair a subscription that already hit it -- see PR discussion for the recovery steps if a routing/balancer rule seems to be pointing at the wrong server on a subscription that predates this fix. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e6e4d47 commit cf42465

2 files changed

Lines changed: 56 additions & 4 deletions

File tree

internal/web/service/outbound_subscription.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -455,13 +455,21 @@ func (s *OutboundSubscriptionService) recordError(sub *model.OutboundSubscriptio
455455

456456
// assignStableTags assigns a tag to each parsed outbound, preferring stability:
457457
// 1. reuse the tag previously mapped to the link's identity (prev),
458-
// 2. else reuse the tag at the same position from the last fetch (prevTagByIndex),
458+
// 2. else reuse the tag at the same position from the last fetch, unless it
459+
// belongs to another identity still present in this batch (prevTagByIndex),
459460
// 3. else allocate a fresh tag from the prefix + remark (link.SuggestTag).
460461
//
461462
// Tags are kept unique within the batch by appending "-N" on collision, and are
462463
// written back into parsed[i]["tag"]. The returned slice holds the assigned tags
463464
// in order. When tagPrefix is empty a "sub<subID>-" prefix is used for fresh tags.
464465
func assignStableTags(parsed []link.Outbound, identities []string, prev map[string]string, prevTagByIndex map[int]string, subID int, tagPrefix string) []string {
466+
reservedStableTags := map[string]bool{}
467+
for i := range parsed {
468+
if i < len(identities) && prev[identities[i]] != "" {
469+
reservedStableTags[prev[identities[i]]] = true
470+
}
471+
}
472+
465473
used := map[string]bool{} // uniqueness within this refresh batch
466474
assigned := make([]string, len(parsed))
467475
for i := range parsed {
@@ -470,12 +478,14 @@ func assignStableTags(parsed []link.Outbound, identities []string, prev map[stri
470478
id = identities[i]
471479
}
472480
candidate := ""
481+
identityTag := ""
473482
if old, ok := prev[id]; ok && old != "" {
474483
candidate = old
484+
identityTag = old
475485
}
476486
if candidate == "" {
477487
// try to reuse by rough positional match from previous fetch (best effort)
478-
if old, ok := prevTagByIndex[i]; ok && old != "" {
488+
if old, ok := prevTagByIndex[i]; ok && old != "" && !reservedStableTags[old] {
479489
candidate = old
480490
}
481491
}
@@ -493,7 +503,7 @@ func assignStableTags(parsed []link.Outbound, identities []string, prev map[stri
493503
}
494504
// ensure local uniqueness inside this batch
495505
final := candidate
496-
for k := 1; used[final]; k++ {
506+
for k := 1; used[final] || (reservedStableTags[final] && final != identityTag); k++ {
497507
final = fmt.Sprintf("%s-%d", candidate, k)
498508
}
499509
used[final] = true

internal/web/service/outbound_subscription_test.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package service
33
import (
44
"bytes"
55
"errors"
6+
"slices"
67
"testing"
78

89
"gorm.io/gorm"
@@ -166,12 +167,53 @@ func TestAssignStableTags(t *testing.T) {
166167

167168
t.Run("falls back to the previous tag at the same position", func(t *testing.T) {
168169
parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
169-
got := assignStableTags(parsed, []string{"id-new"}, map[string]string{}, map[int]string{0: "sub1-oldpos"}, 1, "")
170+
prev := map[string]string{"id-gone": "sub1-oldpos"}
171+
got := assignStableTags(parsed, []string{"id-new"}, prev, map[int]string{0: "sub1-oldpos"}, 1, "")
170172
if got[0] != "sub1-oldpos" {
171173
t.Fatalf("got %q, want sub1-oldpos", got[0])
172174
}
173175
})
174176

177+
t.Run("does not let an inserted link steal a stable tag", func(t *testing.T) {
178+
parsed := []link.Outbound{{"tag": "Poland"}, {"tag": "NewServer"}, {"tag": "Netherlands"}}
179+
prev := map[string]string{
180+
"id-poland": "sub1-poland",
181+
"id-netherlands": "sub1-netherlands",
182+
}
183+
prevTagByIndex := map[int]string{0: "sub1-poland", 1: "sub1-netherlands"}
184+
185+
got := assignStableTags(parsed, []string{"id-poland", "id-new", "id-netherlands"}, prev, prevTagByIndex, 1, "")
186+
want := []string{"sub1-poland", "sub1-newserver", "sub1-netherlands"}
187+
if !slices.Equal(got, want) {
188+
t.Fatalf("got %v, want %v", got, want)
189+
}
190+
})
191+
192+
t.Run("does not let a fresh tag steal a stable tag", func(t *testing.T) {
193+
parsed := []link.Outbound{{"tag": "Netherlands"}, {"tag": "Renamed"}}
194+
prev := map[string]string{"id-netherlands": "sub1-netherlands"}
195+
196+
got := assignStableTags(parsed, []string{"id-new", "id-netherlands"}, prev, nil, 1, "")
197+
want := []string{"sub1-netherlands-1", "sub1-netherlands"}
198+
if !slices.Equal(got, want) {
199+
t.Fatalf("got %v, want %v", got, want)
200+
}
201+
})
202+
203+
t.Run("skips reserved tags while adding a suffix", func(t *testing.T) {
204+
parsed := []link.Outbound{{"tag": "Netherlands"}, {"tag": "First"}, {"tag": "Second"}}
205+
prev := map[string]string{
206+
"id-first": "sub1-netherlands",
207+
"id-second": "sub1-netherlands-1",
208+
}
209+
210+
got := assignStableTags(parsed, []string{"id-new", "id-first", "id-second"}, prev, nil, 1, "")
211+
want := []string{"sub1-netherlands-2", "sub1-netherlands", "sub1-netherlands-1"}
212+
if !slices.Equal(got, want) {
213+
t.Fatalf("got %v, want %v", got, want)
214+
}
215+
})
216+
175217
t.Run("allocates a fresh tag with the default sub<id>- prefix", func(t *testing.T) {
176218
parsed := []link.Outbound{{"tag": "Tokyo"}}
177219
got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 7, "")

0 commit comments

Comments
 (0)