mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 23:35:00 +03:00
perf(sub): cache each inbound's settings without clients for subscription bodies (#6758)
Every subscription fetch read and scanned each inbound's whole settings blob, about 25 MB at 100k clients, although subscription bodies resolve their clients from the clients table and never use its clients array. Each inbound's settings minus the clients array are now cached, keyed by inbound and shared by all its subscribers; the inbound row, clients, stats and hosts are still read per fetch, and WireGuard and AmneziaWG keep full settings for their peers. Panel edits purge the cache through an invalidator registry the subscription server releases on stop, with a 10 s TTL as the backstop. At 100k clients a hit takes 2.1 ms on SQLite (72.5 ms before) and 20.7 ms on PostgreSQL (332 ms).
This commit is contained in:
@@ -88,6 +88,8 @@ type SUBController struct {
|
||||
|
||||
subTemplateMu sync.RWMutex
|
||||
subTemplateCache map[string]*cachedSubTemplate
|
||||
|
||||
releaseSettingsCache func()
|
||||
}
|
||||
|
||||
type subControllerConfig struct {
|
||||
@@ -282,6 +284,11 @@ func defaultSUBControllerConfig() subControllerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// Close unhooks the controller's settings cache from panel mutations.
|
||||
func (a *SUBController) Close() {
|
||||
a.releaseSettingsCache()
|
||||
}
|
||||
|
||||
// NewSUBController creates a new subscription controller with the given configuration.
|
||||
func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBController {
|
||||
config := defaultSUBControllerConfig()
|
||||
@@ -290,6 +297,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
|
||||
}
|
||||
|
||||
sub := NewSubService(config.remarkTemplate)
|
||||
releaseSettingsCache := sub.enableSettingsCache()
|
||||
subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, config.subJsonRoutingRules, sub)
|
||||
subJsonSvc.SetObservatoryConfig(config.subJsonObservatory)
|
||||
subJsonSvc.SetDnsConfig(config.subJsonDns)
|
||||
@@ -326,7 +334,8 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
|
||||
subJsonService: subJsonSvc,
|
||||
subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, config.subClashRemoteDNS, sub),
|
||||
|
||||
subTemplateCache: map[string]*cachedSubTemplate{},
|
||||
subTemplateCache: map[string]*cachedSubTemplate{},
|
||||
releaseSettingsCache: releaseSettingsCache,
|
||||
}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
|
||||
+57
-11
@@ -77,10 +77,13 @@ type SubService struct {
|
||||
// with the clients array left out; generators read only inbound-level
|
||||
// fields (encryption, method, version, …) from it.
|
||||
settingsByInbound map[int]map[string]any
|
||||
hostsByInbound map[int][]*model.Host
|
||||
hostGroupOrders map[string]hostEntryOrder
|
||||
hostsFirst bool
|
||||
sharedGroupSni map[string]string
|
||||
// settingsCache is shared by this service's requests (ForRequest copies the pointer); only
|
||||
// subscription bodies read through it, never panel link rendering.
|
||||
settingsCache *inboundSettingsCache
|
||||
hostsByInbound map[int][]*model.Host
|
||||
hostGroupOrders map[string]hostEntryOrder
|
||||
hostsFirst bool
|
||||
sharedGroupSni map[string]string
|
||||
}
|
||||
|
||||
// NewSubService creates a new subscription service with the given configuration.
|
||||
@@ -90,6 +93,13 @@ func NewSubService(remarkTemplate string) *SubService {
|
||||
}
|
||||
}
|
||||
|
||||
// enableSettingsCache gives the subscription server's service its inbound settings cache, purged
|
||||
// on every panel mutation; release unhooks it when the server stops.
|
||||
func (s *SubService) enableSettingsCache() (release func()) {
|
||||
s.settingsCache = newInboundSettingsCache(settingsCacheTTL)
|
||||
return service.RegisterSubDataInvalidator(s.settingsCache.purgeAll)
|
||||
}
|
||||
|
||||
// ForRequest returns a shallow copy with request-scoped state populated.
|
||||
// Subscription controllers share one base SubService, so request-specific
|
||||
// fields such as address and nodesByID must live on a per-request copy.
|
||||
@@ -725,10 +735,22 @@ func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// getInboundsBySubId loads the subscriber's inbounds; hosts are read per request, never cached.
|
||||
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
|
||||
inbounds, err := s.loadInboundsBySubId(subId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.loadSubscriptionHosts(inbounds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inbounds, nil
|
||||
}
|
||||
|
||||
func (s *SubService) loadInboundsBySubId(subId string) ([]*model.Inbound, error) {
|
||||
db := database.GetDB()
|
||||
var inbounds []*model.Inbound
|
||||
err := db.Model(model.Inbound{}).Where(`id in (
|
||||
cached := s.settingsCache != nil && s.subscriptionBody
|
||||
query := db.Model(model.Inbound{}).Where(`id in (
|
||||
SELECT DISTINCT inbounds.id
|
||||
FROM inbounds
|
||||
JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
|
||||
@@ -736,17 +758,41 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
|
||||
WHERE
|
||||
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto','tuic','masque')
|
||||
AND clients.sub_id = ? AND inbounds.enable = ?
|
||||
)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
|
||||
if err != nil {
|
||||
)`, subId, true).Order("sub_sort_index ASC").Order("id ASC")
|
||||
if cached {
|
||||
query = query.Omit("settings")
|
||||
}
|
||||
var inbounds []*model.Inbound
|
||||
if err := query.Find(&inbounds).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cached {
|
||||
for _, inbound := range inbounds {
|
||||
settings, err := s.subscriptionSettings(inbound)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inbound.Settings = settings
|
||||
}
|
||||
}
|
||||
s.indexStatsBySubId(subId)
|
||||
if err := s.loadSubscriptionHosts(inbounds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inbounds, nil
|
||||
}
|
||||
|
||||
// subscriptionSettings is an inbound's settings for a subscription body: WireGuard and AmneziaWG
|
||||
// keep their peers, every other protocol gets the shared copy without the clients array.
|
||||
func (s *SubService) subscriptionSettings(inbound *model.Inbound) (string, error) {
|
||||
load := func() (string, error) {
|
||||
var settings string
|
||||
err := database.GetDB().Model(&model.Inbound{}).Select("settings").Where("id = ?", inbound.Id).Row().Scan(&settings)
|
||||
return settings, err
|
||||
}
|
||||
if inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG {
|
||||
return load()
|
||||
}
|
||||
return s.settingsCache.settingsFor(inbound.Id, load)
|
||||
}
|
||||
|
||||
// indexStatsBySubId loads the traffic rows for just this subscriber's clients
|
||||
// into statsByEmail so statsForClient can resolve a client's usage on any of
|
||||
// its inbounds. It replaces preloading every matched inbound's ClientStats,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// Subscription apps poll on multi-minute intervals, so inbound settings at most this old read the
|
||||
// same as fresh ones; panel edits purge them at once.
|
||||
const settingsCacheTTL = 10 * time.Second
|
||||
|
||||
// inboundSettingsCache keeps each inbound's settings without its clients array for subscription
|
||||
// bodies, so every subscriber of a large inbound shares one small copy instead of the whole blob.
|
||||
type inboundSettingsCache struct {
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
group singleflight.Group
|
||||
|
||||
mu sync.Mutex
|
||||
generation uint64
|
||||
entries map[int]settingsEntry
|
||||
}
|
||||
|
||||
type settingsEntry struct {
|
||||
settings string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func newInboundSettingsCache(ttl time.Duration) *inboundSettingsCache {
|
||||
return &inboundSettingsCache{ttl: ttl, now: time.Now, entries: map[int]settingsEntry{}}
|
||||
}
|
||||
|
||||
// settingsFor returns the inbound's client-less settings, loading them at most once per TTL window
|
||||
// even when concurrent requests miss together.
|
||||
func (c *inboundSettingsCache) settingsFor(inboundId int, load func() (string, error)) (string, error) {
|
||||
if settings, ok := c.lookup(inboundId); ok {
|
||||
return settings, nil
|
||||
}
|
||||
v, err, _ := c.group.Do(strconv.Itoa(inboundId), func() (any, error) {
|
||||
if settings, ok := c.lookup(inboundId); ok {
|
||||
return settings, nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
generation := c.generation
|
||||
c.mu.Unlock()
|
||||
raw, err := load()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
settings := withoutClients(raw)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
// A purge during the load means the row may have changed under it; serve it, never keep it.
|
||||
if generation == c.generation {
|
||||
c.entries[inboundId] = settingsEntry{settings: settings, expiresAt: c.now().Add(c.ttl)}
|
||||
}
|
||||
return settings, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return v.(string), nil
|
||||
}
|
||||
|
||||
func (c *inboundSettingsCache) lookup(inboundId int) (string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
entry, ok := c.entries[inboundId]
|
||||
if !ok || !c.now().Before(entry.expiresAt) {
|
||||
return "", false
|
||||
}
|
||||
return entry.settings, true
|
||||
}
|
||||
|
||||
// purgeAll drops every entry after a panel mutation, so an edit reaches the next fetch.
|
||||
func (c *inboundSettingsCache) purgeAll() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.generation++
|
||||
clear(c.entries)
|
||||
}
|
||||
|
||||
// withoutClients drops the clients array, nearly all of a large inbound's settings, which
|
||||
// subscription bodies resolve from the clients table instead; malformed settings stay as they are.
|
||||
func withoutClients(settings string) string {
|
||||
var shallow map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(settings), &shallow); err != nil {
|
||||
return settings
|
||||
}
|
||||
if _, ok := shallow["clients"]; !ok {
|
||||
return settings
|
||||
}
|
||||
delete(shallow, "clients")
|
||||
out, err := json.Marshal(shallow)
|
||||
if err != nil {
|
||||
return settings
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
func TestInboundSettingsCacheHitExpiryAndPurge(t *testing.T) {
|
||||
now := time.Unix(1_000_000, 0)
|
||||
cache := newInboundSettingsCache(10 * time.Second)
|
||||
cache.now = func() time.Time { return now }
|
||||
var loads atomic.Int32
|
||||
load := func() (string, error) {
|
||||
loads.Add(1)
|
||||
return `{"clients":[{"email":"a@e"}],"decryption":"none"}`, nil
|
||||
}
|
||||
|
||||
got, err := cache.settingsFor(1, load)
|
||||
if err != nil {
|
||||
t.Fatalf("miss: %v", err)
|
||||
}
|
||||
if got != `{"decryption":"none"}` {
|
||||
t.Fatalf("settings = %s, want the clients array dropped", got)
|
||||
}
|
||||
if _, err := cache.settingsFor(1, load); err != nil || loads.Load() != 1 {
|
||||
t.Fatalf("hit reloaded: loads = %d, err = %v", loads.Load(), err)
|
||||
}
|
||||
now = now.Add(11 * time.Second)
|
||||
if _, err := cache.settingsFor(1, load); err != nil || loads.Load() != 2 {
|
||||
t.Fatalf("expired entry must reload: loads = %d, err = %v", loads.Load(), err)
|
||||
}
|
||||
cache.purgeAll()
|
||||
if _, err := cache.settingsFor(1, load); err != nil || loads.Load() != 3 {
|
||||
t.Fatalf("purged entry must reload: loads = %d, err = %v", loads.Load(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundSettingsCacheSingleFlight(t *testing.T) {
|
||||
cache := newInboundSettingsCache(time.Minute)
|
||||
release := make(chan struct{})
|
||||
var loads atomic.Int32
|
||||
load := func() (string, error) {
|
||||
loads.Add(1)
|
||||
<-release
|
||||
return `{"method":"aes-128-gcm"}`, nil
|
||||
}
|
||||
|
||||
const callers = 8
|
||||
var wg sync.WaitGroup
|
||||
for range callers {
|
||||
wg.Go(func() {
|
||||
if got, err := cache.settingsFor(7, load); err != nil || got != `{"method":"aes-128-gcm"}` {
|
||||
t.Errorf("settingsFor = %q, %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Let every caller reach the shared load before releasing it.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(release)
|
||||
wg.Wait()
|
||||
if loads.Load() != 1 {
|
||||
t.Fatalf("%d concurrent misses ran %d loads, want 1", callers, loads.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// A load that a panel edit overlaps may have read the row before the edit; it is served once but
|
||||
// must not be kept, or the edit would stay invisible for a whole TTL.
|
||||
func TestInboundSettingsCacheDropsALoadOverlappingAPurge(t *testing.T) {
|
||||
cache := newInboundSettingsCache(time.Minute)
|
||||
var loads atomic.Int32
|
||||
load := func() (string, error) {
|
||||
if loads.Add(1) == 1 {
|
||||
cache.purgeAll()
|
||||
}
|
||||
return `{"decryption":"none"}`, nil
|
||||
}
|
||||
if _, err := cache.settingsFor(3, load); err != nil {
|
||||
t.Fatalf("first read: %v", err)
|
||||
}
|
||||
if _, err := cache.settingsFor(3, load); err != nil {
|
||||
t.Fatalf("second read: %v", err)
|
||||
}
|
||||
if loads.Load() != 2 {
|
||||
t.Fatalf("loads = %d, want 2: a load that overlapped a purge was kept", loads.Load())
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
parityServerKey = "c2VydmVya2V5MTIzNDU2Nw=="
|
||||
parityClientKey = "Y2xpZW50a2V5MTIzNDU2Nw=="
|
||||
)
|
||||
|
||||
// seedParitySubscription gives subscriber "par" a VLESS, a Shadowsocks 2022 and a WireGuard
|
||||
// inbound, whose links read the top-level settings, the server key and the peers respectively.
|
||||
func seedParitySubscription(t *testing.T) {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
seedSubProtocolInbound(t, "par", "pv", 4701, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`, model.VLESS)
|
||||
ss := seedSubProtocolInbound(t, "par", "ps", 4702, 2, `{"network":"tcp","security":"none"}`, model.Shadowsocks)
|
||||
ssSettings := fmt.Sprintf(`{"method":"2022-blake3-aes-128-gcm","password":%q,"network":"tcp,udp","clients":[{"email":"ps@e","password":%q,"subId":"par","enable":true}]}`, parityServerKey, parityClientKey)
|
||||
if err := db.Model(ss).Update("settings", ssSettings).Error; err != nil {
|
||||
t.Fatalf("shadowsocks settings: %v", err)
|
||||
}
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "ps@e").Update("password", parityClientKey).Error; err != nil {
|
||||
t.Fatalf("shadowsocks client password: %v", err)
|
||||
}
|
||||
serverPriv, _ := mustWireguardKeypair(t)
|
||||
clientPriv, _ := mustWireguardKeypair(t)
|
||||
wgSettings := fmt.Sprintf(`{"secretKey":%q,"clients":[{"email":"pw@e","privateKey":%q,"allowedIPs":["10.0.0.2/32"],"enable":true}]}`, serverPriv, clientPriv)
|
||||
seedTunnelSubInbound(t, model.WireGuard, "pw", "par", "pw@e", wgSettings, 4703)
|
||||
}
|
||||
|
||||
// renderSubscription is everything a subscriber can fetch: the raw links, the JSON and the Clash body.
|
||||
func renderSubscription(t *testing.T, svc *SubService) []string {
|
||||
t.Helper()
|
||||
req := svc.ForRequest("req.example.com")
|
||||
req.subscriptionBody = true
|
||||
links, _, _, _, err := req.getSubs("par")
|
||||
if err != nil {
|
||||
t.Fatalf("getSubs: %v", err)
|
||||
}
|
||||
if len(links) != 3 {
|
||||
t.Fatalf("links = %d, want one per inbound: %v", len(links), links)
|
||||
}
|
||||
jsonBody, _, err := NewSubJsonService("", "", "", "", svc).GetJson("par", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
clashBody, _, err := NewSubClashService(false, "", false, svc).GetClash("par", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
}
|
||||
return []string{strings.Join(links, "\n"), jsonBody, clashBody}
|
||||
}
|
||||
|
||||
// The cache hands generators settings without the clients array; every format must still render
|
||||
// exactly what the full settings render, from the miss and from the hit alike.
|
||||
func TestSettingsCacheRendersTheSameSubscription(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
seedParitySubscription(t)
|
||||
|
||||
want := renderSubscription(t, NewSubService(""))
|
||||
cached := NewSubService("")
|
||||
t.Cleanup(cached.enableSettingsCache())
|
||||
for round := range 2 {
|
||||
got := renderSubscription(t, cached)
|
||||
for i, format := range []string{"raw", "json", "clash"} {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("round %d %s differs with the settings cache:\n got: %s\nwant: %s", round, format, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(want[0], "ss://") {
|
||||
t.Fatalf("raw subscription lost its shadowsocks link: %s", want[0])
|
||||
}
|
||||
if n := len(cached.settingsCache.entries); n != 2 {
|
||||
t.Fatalf("cached %d inbounds, want 2: VLESS and Shadowsocks, never the WireGuard peers", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the settings blob is cached: the inbound row is read on every fetch, so a port change or a
|
||||
// disabled inbound reaches the next fetch, while settings wait for a panel edit's purge.
|
||||
func TestSettingsCacheReadsTheInboundRowFresh(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "fresh", 4611, 1, `{"network":"tcp","security":"none"}`)
|
||||
cached := NewSubService("")
|
||||
t.Cleanup(cached.enableSettingsCache())
|
||||
cached.subscriptionBody = true
|
||||
|
||||
if _, err := cached.getInboundsBySubId("s1"); err != nil {
|
||||
t.Fatalf("priming read: %v", err)
|
||||
}
|
||||
db := database.GetDB()
|
||||
if err := db.Model(ib).Updates(map[string]any{"port": 4612, "settings": `{"clients":[],"decryption":"changed"}`}).Error; err != nil {
|
||||
t.Fatalf("edit inbound: %v", err)
|
||||
}
|
||||
inbounds, err := cached.getInboundsBySubId("s1")
|
||||
if err != nil {
|
||||
t.Fatalf("hit read: %v", err)
|
||||
}
|
||||
if len(inbounds) != 1 || inbounds[0].Port != 4612 {
|
||||
t.Fatalf("inbounds = %+v, want the new port on the next fetch", inbounds)
|
||||
}
|
||||
if strings.Contains(inbounds[0].Settings, "changed") {
|
||||
t.Fatal("settings changed without a purge must still come from the cache")
|
||||
}
|
||||
service.InvalidateSubData()
|
||||
inbounds, err = cached.getInboundsBySubId("s1")
|
||||
if err != nil {
|
||||
t.Fatalf("post-purge read: %v", err)
|
||||
}
|
||||
if len(inbounds) != 1 || !strings.Contains(inbounds[0].Settings, "changed") {
|
||||
t.Fatalf("settings = %+v, want the edit after a purge", inbounds)
|
||||
}
|
||||
|
||||
if err := db.Model(ib).Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("disable inbound: %v", err)
|
||||
}
|
||||
if inbounds, err = cached.getInboundsBySubId("s1"); err != nil || len(inbounds) != 0 {
|
||||
t.Fatalf("disabled inbound still served: %d inbounds, err %v", len(inbounds), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Host rows have no invalidation hooks, so a cached fetch must still read them fresh: a host added
|
||||
// between two reads has to show up on the second.
|
||||
func TestGetInboundsBySubIdLoadsHostsOnCacheHit(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "hh", 4603, 1, `{"network":"tcp","security":"none"}`)
|
||||
cached := NewSubService("")
|
||||
t.Cleanup(cached.enableSettingsCache())
|
||||
cached.subscriptionBody = true
|
||||
|
||||
if _, err := cached.getInboundsBySubId("s1"); err != nil {
|
||||
t.Fatalf("priming read: %v", err)
|
||||
}
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, Remark: "edge", Address: "edge.example.com", SortOrder: 1})
|
||||
if _, err := cached.getInboundsBySubId("s1"); err != nil {
|
||||
t.Fatalf("hit read: %v", err)
|
||||
}
|
||||
if len(cached.hostsByInbound[ib.Id]) != 1 {
|
||||
t.Fatalf("cache hit did not load fresh host rows: %+v", cached.hostsByInbound)
|
||||
}
|
||||
}
|
||||
|
||||
// Panel link rendering registered an invalidator per render and nothing ever unregistered one; a
|
||||
// released invalidator must stop running.
|
||||
func TestSubDataInvalidatorRelease(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
release := service.RegisterSubDataInvalidator(func() { calls.Add(1) })
|
||||
service.InvalidateSubData()
|
||||
release()
|
||||
service.InvalidateSubData()
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("invalidator ran %d times, want 1: a released invalidator kept running", calls.Load())
|
||||
}
|
||||
}
|
||||
@@ -466,6 +466,9 @@ func (s *Server) Start() (err error) {
|
||||
// Stop gracefully shuts down the subscription server and closes the listener.
|
||||
func (s *Server) Stop() error {
|
||||
s.cancel()
|
||||
if s.sub != nil {
|
||||
s.sub.Close()
|
||||
}
|
||||
|
||||
var err1 error
|
||||
var err2 error
|
||||
|
||||
Reference in New Issue
Block a user