fix(group): bound push pressure and synchronize load agents

This commit is contained in:
richjohnrambo
2026-10-04 00:49:27 +08:00
parent 746f75ab0f
commit 7b25365ec3
14 changed files with 476 additions and 47 deletions
+3 -1
View File
@@ -89,7 +89,7 @@
"max_idle_conns": 8,
"registry_max_open_conns": 4,
"registry_max_idle_conns": 2,
"conn_max_lifetime": 60,
"conn_max_lifetime": 1800,
"sql_timeout": 10
}
},
@@ -229,6 +229,8 @@
"enabled": false,
"api_domain": "https://api.jpush.cn",
"push_path": "/v3/push",
"workers": 8,
"device_cache_ttl_seconds": 30,
"default_ttl_seconds": 86400,
"call_ttl_seconds": 60,
"apns_production": false,
+164 -21
View File
@@ -12,6 +12,7 @@ import (
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/tinode/chat/server/logs"
@@ -21,16 +22,39 @@ import (
)
const (
defaultBufferSize = 1024
defaultAPIDomain = "https://api.jpush.cn"
defaultPushPath = "/v3/push"
defaultTTL = 86400
defaultCallTTL = 30
callCategory = "im_call"
defaultBufferSize = 1024
defaultWorkers = 8
defaultAPIDomain = "https://api.jpush.cn"
defaultPushPath = "/v3/push"
defaultTTL = 86400
defaultCallTTL = 30
defaultDeviceCacheTTL = 30
callCategory = "im_call"
)
var handler Handler
type deviceCacheKey struct {
tenantID t.TenantID
provider string
uid t.Uid
}
type deviceCacheEntry struct {
devices []t.DeviceDef
expiresAt time.Time
staleUntil time.Time
}
type providerDeviceCache struct {
mu sync.Mutex
entries map[deviceCacheKey]deviceCacheEntry
retryAfter time.Time
nextCleanup time.Time
}
var jpushDeviceCache = providerDeviceCache{entries: make(map[deviceCacheKey]deviceCacheEntry)}
type Handler struct {
input chan *push.Receipt
channel chan *push.ChannelReq
@@ -40,19 +64,21 @@ type Handler struct {
}
type configType struct {
Enabled bool `json:"enabled"`
DryRun bool `json:"dry_run"`
AppKey string `json:"app_key"`
MasterSecret string `json:"master_secret"`
APIDomain string `json:"api_domain"`
PushPath string `json:"push_path"`
Buffer int `json:"buffer"`
DefaultTTLSeconds int `json:"default_ttl_seconds"`
CallTTLSeconds int `json:"call_ttl_seconds"`
APNsProduction bool `json:"apns_production"`
UseAlias *bool `json:"use_alias"`
Classification int `json:"classification"`
Title string `json:"title"`
Enabled bool `json:"enabled"`
DryRun bool `json:"dry_run"`
AppKey string `json:"app_key"`
MasterSecret string `json:"master_secret"`
APIDomain string `json:"api_domain"`
PushPath string `json:"push_path"`
Buffer int `json:"buffer"`
Workers int `json:"workers"`
DefaultTTLSeconds int `json:"default_ttl_seconds"`
CallTTLSeconds int `json:"call_ttl_seconds"`
DeviceCacheTTLSeconds int `json:"device_cache_ttl_seconds"`
APNsProduction bool `json:"apns_production"`
UseAlias *bool `json:"use_alias"`
Classification int `json:"classification"`
Title string `json:"title"`
}
type jpushPayload struct {
@@ -154,10 +180,19 @@ func (Handler) Init(jsonconf json.RawMessage) (bool, error) {
handler.config = normalized
go func() {
workers := make(chan struct{}, normalized.Workers)
for {
select {
case rcpt := <-handler.input:
go sendReceipt(rcpt, &handler.config, handler.client)
select {
case workers <- struct{}{}:
go func(receipt *push.Receipt) {
defer func() { <-workers }()
sendReceipt(receipt, &handler.config, handler.client)
}(rcpt)
case <-handler.stop:
return
}
case req := <-handler.channel:
logs.Info.Println("jpush: channel subscription ignored", req.Uid.UserId(), req.Channel)
case <-handler.stop:
@@ -186,12 +221,18 @@ func normalizeConfig(config configType) (configType, error) {
if config.Buffer <= 0 {
config.Buffer = defaultBufferSize
}
if config.Workers <= 0 {
config.Workers = defaultWorkers
}
if config.DefaultTTLSeconds <= 0 {
config.DefaultTTLSeconds = defaultTTL
}
if config.CallTTLSeconds <= 0 {
config.CallTTLSeconds = defaultCallTTL
}
if config.DeviceCacheTTLSeconds <= 0 {
config.DeviceCacheTTLSeconds = defaultDeviceCacheTTL
}
if config.Title == "" {
config.Title = "MeetingIM"
}
@@ -260,6 +301,8 @@ func loadThirdPartyConfig(config *configType) {
"jpush_master_secret",
"jpush_api_domain",
"jpush_push_path",
"jpush_workers",
"jpush_device_cache_ttl_seconds",
"jpush_default_ttl_seconds",
"jpush_call_ttl_seconds",
"jpush_apns_production",
@@ -285,6 +328,12 @@ func loadThirdPartyConfig(config *configType) {
if v := strings.TrimSpace(values["jpush_push_path"]); v != "" {
config.PushPath = v
}
if v, ok := parseConfigInt(values["jpush_workers"]); ok {
config.Workers = v
}
if v, ok := parseConfigInt(values["jpush_device_cache_ttl_seconds"]); ok {
config.DeviceCacheTTLSeconds = v
}
if v, ok := parseConfigInt(values["jpush_default_ttl_seconds"]); ok {
config.DefaultTTLSeconds = v
}
@@ -344,7 +393,8 @@ func preparePayloads(rcpt *push.Receipt, config *configType) ([]*jpushPayload, [
}
var devices map[t.Uid][]t.DeviceDef
if !useAlias {
devices, _, err = providerDevicesForUsers(rcpt.TenantID, "jpush", uids...)
devices, _, err = cachedProviderDevicesForUsers(rcpt.TenantID, "jpush",
time.Duration(config.DeviceCacheTTLSeconds)*time.Second, uids...)
if err != nil {
logs.Warn.Println("jpush: db error", err)
return nil, nil
@@ -699,6 +749,99 @@ func providerDevicesForUsers(tenantID t.TenantID, provider string, uids ...t.Uid
return map[t.Uid][]t.DeviceDef{}, 0, nil
}
func cachedProviderDevicesForUsers(tenantID t.TenantID, provider string, ttl time.Duration,
uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
if ttl <= 0 {
ttl = defaultDeviceCacheTTL * time.Second
}
provider = strings.ToLower(strings.TrimSpace(provider))
now := time.Now()
cache := &jpushDeviceCache
cache.mu.Lock()
defer cache.mu.Unlock()
result := make(map[t.Uid][]t.DeviceDef, len(uids))
misses := make([]t.Uid, 0, len(uids))
for _, uid := range uids {
key := deviceCacheKey{tenantID: tenantID, provider: provider, uid: uid}
entry, ok := cache.entries[key]
if ok && now.Before(entry.expiresAt) {
result[uid] = append([]t.DeviceDef(nil), entry.devices...)
continue
}
misses = append(misses, uid)
}
if len(misses) == 0 {
return result, countDevices(result), nil
}
if cache.nextCleanup.IsZero() || !now.Before(cache.nextCleanup) {
for key, entry := range cache.entries {
if !now.Before(entry.staleUntil) {
delete(cache.entries, key)
}
}
cache.nextCleanup = now.Add(time.Minute)
}
// A failed lookup used to trigger one identical database query per group
// message. Back off briefly and use stale entries where possible instead.
if now.Before(cache.retryAfter) {
cache.addStale(result, tenantID, provider, now, misses)
return result, countDevices(result), nil
}
loaded, _, err := providerDevicesForUsers(tenantID, provider, misses...)
if err != nil {
cache.retryAfter = now.Add(time.Second)
if cache.addStale(result, tenantID, provider, now, misses) == len(misses) {
return result, countDevices(result), nil
}
return result, countDevices(result), err
}
cache.retryAfter = time.Time{}
staleTTL := 10 * ttl
if staleTTL < 5*time.Minute {
staleTTL = 5 * time.Minute
}
for _, uid := range misses {
devices := append([]t.DeviceDef(nil), loaded[uid]...)
cache.entries[deviceCacheKey{tenantID: tenantID, provider: provider, uid: uid}] = deviceCacheEntry{
devices: devices, expiresAt: now.Add(ttl), staleUntil: now.Add(staleTTL),
}
result[uid] = append([]t.DeviceDef(nil), devices...)
}
return result, countDevices(result), nil
}
func (c *providerDeviceCache) addStale(result map[t.Uid][]t.DeviceDef, tenantID t.TenantID,
provider string, now time.Time, uids []t.Uid) int {
covered := 0
for _, uid := range uids {
entry, ok := c.entries[deviceCacheKey{tenantID: tenantID, provider: provider, uid: uid}]
if ok && now.Before(entry.staleUntil) {
result[uid] = append([]t.DeviceDef(nil), entry.devices...)
covered++
}
}
return covered
}
func countDevices(devices map[t.Uid][]t.DeviceDef) int {
count := 0
for _, entries := range devices {
count += len(entries)
}
return count
}
func resetDeviceCache() {
jpushDeviceCache.mu.Lock()
jpushDeviceCache.entries = make(map[deviceCacheKey]deviceCacheEntry)
jpushDeviceCache.retryAfter = time.Time{}
jpushDeviceCache.nextCleanup = time.Time{}
jpushDeviceCache.mu.Unlock()
}
func (Handler) IsReady() bool {
return handler.input != nil
}
+78 -12
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
@@ -27,32 +28,39 @@ func (s testThirdPartyConfigStore) Get(_ string, keys ...string) (map[string]str
type testDevicePushTokenStore struct {
devices map[types.Uid][]types.DeviceDef
calls int
}
func (s *testDevicePushTokenStore) Upsert(types.TenantID, *types.DevicePushToken) (*types.DevicePushToken, error) {
return nil, nil
}
func (s *testDevicePushTokenStore) GetAllByProvider(types.TenantID, string, ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
func (s *testDevicePushTokenStore) GetAllByProvider(_ types.TenantID, _ string, uids ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
s.calls++
result := make(map[types.Uid][]types.DeviceDef, len(uids))
count := 0
for _, devices := range s.devices {
for _, uid := range uids {
devices := s.devices[uid]
result[uid] = devices
count += len(devices)
}
return s.devices, count, nil
return result, count, nil
}
func TestNormalizeConfigLoadsThirdPartyConfig(t *testing.T) {
oldConfig := store.ThirdPartyConfig
store.ThirdPartyConfig = testThirdPartyConfigStore{
"jpush_enabled": "1",
"jpush_app_key": "app-key",
"jpush_master_secret": "master",
"jpush_api_domain": "https://push.example.com/",
"jpush_push_path": "v3/push",
"jpush_default_ttl_seconds": "123",
"jpush_call_ttl_seconds": "45",
"jpush_use_alias": "false",
"jpush_classification": "1",
"jpush_enabled": "1",
"jpush_app_key": "app-key",
"jpush_master_secret": "master",
"jpush_api_domain": "https://push.example.com/",
"jpush_push_path": "v3/push",
"jpush_workers": "4",
"jpush_device_cache_ttl_seconds": "45",
"jpush_default_ttl_seconds": "123",
"jpush_call_ttl_seconds": "45",
"jpush_use_alias": "false",
"jpush_classification": "1",
}
defer func() { store.ThirdPartyConfig = oldConfig }()
@@ -68,6 +76,9 @@ func TestNormalizeConfigLoadsThirdPartyConfig(t *testing.T) {
if config.APIDomain != "https://push.example.com" || config.PushPath != "/v3/push" {
t.Fatalf("unexpected endpoint: %#v", config)
}
if config.Workers != 4 || config.DeviceCacheTTLSeconds != 45 {
t.Fatalf("unexpected worker/cache config: %#v", config)
}
if config.DefaultTTLSeconds != 123 || config.CallTTLSeconds != 45 || config.UseAlias == nil || *config.UseAlias {
t.Fatalf("unexpected ttl/use_alias: %#v", config)
}
@@ -119,6 +130,8 @@ func TestAliasForUidUsesJPushSafeCharacters(t *testing.T) {
}
func TestPreparePayloadsCanUseRegistrationID(t *testing.T) {
resetDeviceCache()
t.Cleanup(resetDeviceCache)
oldTokens := store.DevicePushTokens
uid := types.Uid(42)
store.DevicePushTokens = &testDevicePushTokenStore{devices: map[types.Uid][]types.DeviceDef{
@@ -147,6 +160,8 @@ func TestPreparePayloadsCanUseRegistrationID(t *testing.T) {
}
func TestPreparePayloadsDoesNotDropRecipientWithDeliveredSession(t *testing.T) {
resetDeviceCache()
t.Cleanup(resetDeviceCache)
oldTokens := store.DevicePushTokens
uid := types.Uid(42)
store.DevicePushTokens = &testDevicePushTokenStore{devices: map[types.Uid][]types.DeviceDef{
@@ -170,6 +185,57 @@ func TestPreparePayloadsDoesNotDropRecipientWithDeliveredSession(t *testing.T) {
}
}
func TestPreparePayloadsCachesEmptyDeviceLookups(t *testing.T) {
resetDeviceCache()
t.Cleanup(resetDeviceCache)
oldTokens := store.DevicePushTokens
tokens := &testDevicePushTokenStore{devices: map[types.Uid][]types.DeviceDef{}}
store.DevicePushTokens = tokens
t.Cleanup(func() { store.DevicePushTokens = oldTokens })
useAlias := false
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60,
DeviceCacheTTLSeconds: 30, UseAlias: &useAlias}
receipt := &push.Receipt{
TenantID: 7,
To: map[types.Uid]push.Recipient{42: {}, 43: {}},
Payload: push.Payload{What: push.ActMsg, Topic: "grpTopic", SeqId: 9, Timestamp: time.Now()},
}
preparePayloads(receipt, config)
preparePayloads(receipt, config)
if tokens.calls != 1 {
t.Fatalf("device store calls=%d want=1", tokens.calls)
}
}
func TestPreparePayloadsCoalescesConcurrentDeviceLookups(t *testing.T) {
resetDeviceCache()
t.Cleanup(resetDeviceCache)
oldTokens := store.DevicePushTokens
tokens := &testDevicePushTokenStore{devices: map[types.Uid][]types.DeviceDef{}}
store.DevicePushTokens = tokens
t.Cleanup(func() { store.DevicePushTokens = oldTokens })
useAlias := false
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60,
DeviceCacheTTLSeconds: 30, UseAlias: &useAlias}
receipt := &push.Receipt{
TenantID: 7,
To: map[types.Uid]push.Recipient{42: {}, 43: {}},
Payload: push.Payload{What: push.ActMsg, Topic: "grpTopic", SeqId: 9, Timestamp: time.Now()},
}
var wg sync.WaitGroup
for range 20 {
wg.Add(1)
go func() {
defer wg.Done()
preparePayloads(receipt, config)
}()
}
wg.Wait()
if tokens.calls != 1 {
t.Fatalf("device store calls=%d want=1", tokens.calls)
}
}
func TestBuildPayloadUsesShortTTLForCalls(t *testing.T) {
expiresAt := time.Now().Add(30 * time.Second).UTC().Format(time.RFC3339Nano)
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60}
+3 -1
View File
@@ -88,7 +88,7 @@
"max_idle_conns": 8,
"registry_max_open_conns": 4,
"registry_max_idle_conns": 2,
"conn_max_lifetime": 60,
"conn_max_lifetime": 1800,
"sql_timeout": 10
}
},
@@ -228,6 +228,8 @@
"enabled": false,
"api_domain": "https://api.jpush.cn",
"push_path": "/v3/push",
"workers": 8,
"device_cache_ttl_seconds": 30,
"default_ttl_seconds": 86400,
"call_ttl_seconds": 60,
"apns_production": false,