fix(group): bound push pressure and synchronize load agents
This commit is contained in:
+164
-21
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user