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:
Шакирзянов Амир
2026-10-24 23:45:46 +03:00
committed by GitHub
parent 8da3b3c344
commit 73fad424ed
13 changed files with 469 additions and 13 deletions
+103
View File
@@ -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)
}