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
+2
View File
@@ -194,6 +194,8 @@ ENV TNPG_ORG=
ENV JPUSH_PUSH_ENABLED=false ENV JPUSH_PUSH_ENABLED=false
ENV JPUSH_API_DOMAIN=https://api.jpush.cn ENV JPUSH_API_DOMAIN=https://api.jpush.cn
ENV JPUSH_PUSH_PATH=/v3/push ENV JPUSH_PUSH_PATH=/v3/push
ENV JPUSH_WORKERS=8
ENV JPUSH_DEVICE_CACHE_TTL_SECONDS=30
ENV JPUSH_DEFAULT_TTL_SECONDS=86400 ENV JPUSH_DEFAULT_TTL_SECONDS=86400
ENV JPUSH_CALL_TTL_SECONDS=60 ENV JPUSH_CALL_TTL_SECONDS=60
ENV JPUSH_APNS_PRODUCTION=false ENV JPUSH_APNS_PRODUCTION=false
+2
View File
@@ -110,6 +110,8 @@ ENV TNPG_PUSH_ENABLED=false
ENV JPUSH_PUSH_ENABLED=false ENV JPUSH_PUSH_ENABLED=false
ENV JPUSH_API_DOMAIN=https://api.jpush.cn ENV JPUSH_API_DOMAIN=https://api.jpush.cn
ENV JPUSH_PUSH_PATH=/v3/push ENV JPUSH_PUSH_PATH=/v3/push
ENV JPUSH_WORKERS=8
ENV JPUSH_DEVICE_CACHE_TTL_SECONDS=30
ENV JPUSH_DEFAULT_TTL_SECONDS=86400 ENV JPUSH_DEFAULT_TTL_SECONDS=86400
ENV JPUSH_CALL_TTL_SECONDS=60 ENV JPUSH_CALL_TTL_SECONDS=60
ENV JPUSH_APNS_PRODUCTION=false ENV JPUSH_APNS_PRODUCTION=false
+3 -1
View File
@@ -85,7 +85,7 @@
"max_idle_conns": $MYSQL_MAX_IDLE_CONNS, "max_idle_conns": $MYSQL_MAX_IDLE_CONNS,
"registry_max_open_conns": $MYSQL_REGISTRY_MAX_OPEN_CONNS, "registry_max_open_conns": $MYSQL_REGISTRY_MAX_OPEN_CONNS,
"registry_max_idle_conns": $MYSQL_REGISTRY_MAX_IDLE_CONNS, "registry_max_idle_conns": $MYSQL_REGISTRY_MAX_IDLE_CONNS,
"conn_max_lifetime": 60, "conn_max_lifetime": $MYSQL_CONN_MAX_LIFETIME_SECONDS,
"sql_timeout": 10, "sql_timeout": 10,
"tenant_registry_db": "$TENANT_REGISTRY_DB", "tenant_registry_db": "$TENANT_REGISTRY_DB",
"tenant_db_routing": true "tenant_db_routing": true
@@ -266,6 +266,8 @@
"enabled": $JPUSH_PUSH_ENABLED, "enabled": $JPUSH_PUSH_ENABLED,
"api_domain": "$JPUSH_API_DOMAIN", "api_domain": "$JPUSH_API_DOMAIN",
"push_path": "$JPUSH_PUSH_PATH", "push_path": "$JPUSH_PUSH_PATH",
"workers": $JPUSH_WORKERS,
"device_cache_ttl_seconds": $JPUSH_DEVICE_CACHE_TTL_SECONDS,
"default_ttl_seconds": $JPUSH_DEFAULT_TTL_SECONDS, "default_ttl_seconds": $JPUSH_DEFAULT_TTL_SECONDS,
"call_ttl_seconds": $JPUSH_CALL_TTL_SECONDS, "call_ttl_seconds": $JPUSH_CALL_TTL_SECONDS,
"apns_production": $JPUSH_APNS_PRODUCTION, "apns_production": $JPUSH_APNS_PRODUCTION,
+1
View File
@@ -24,6 +24,7 @@ else
: "${MYSQL_MAX_IDLE_CONNS:=8}" : "${MYSQL_MAX_IDLE_CONNS:=8}"
: "${MYSQL_REGISTRY_MAX_OPEN_CONNS:=4}" : "${MYSQL_REGISTRY_MAX_OPEN_CONNS:=4}"
: "${MYSQL_REGISTRY_MAX_IDLE_CONNS:=2}" : "${MYSQL_REGISTRY_MAX_IDLE_CONNS:=2}"
: "${MYSQL_CONN_MAX_LIFETIME_SECONDS:=1800}"
if [ -z "$CLUSTER_NODES_JSON" ] ; then if [ -z "$CLUSTER_NODES_JSON" ] ; then
CLUSTER_NODES_JSON='[{"name":"tinode-0","addr":"tinode-0:12000"},{"name":"tinode-1","addr":"tinode-1:12001"},{"name":"tinode-2","addr":"tinode-2:12002"}]' CLUSTER_NODES_JSON='[{"name":"tinode-0","addr":"tinode-0:12000"},{"name":"tinode-1","addr":"tinode-1:12001"},{"name":"tinode-2","addr":"tinode-2:12002"}]'
fi fi
@@ -737,7 +737,7 @@ loadtest/im-pressure/internal/collector/
loadtest/im-pressure/internal/report/ loadtest/im-pressure/internal/report/
``` ```
协调方式:Machine A 启动 coordinator,等待 A/B 两个 agent 都上报 1000 个 READY,然后发布一个至少晚于当前时间 30 秒的 UTC `start_at`。两台机器按相同 `run_id` 和确定性调度执行。任何 agent 失联都将本轮标记为 `INVALID`。 协调方式:Machine A 启动 coordinator,等待 A/B 两个 agent 都上报 1000 个 READY,然后发布一个至少晚于当前时间 30 秒的 UTC `start_at`。到达 `start_at` 后,A/B 必须在前后 3 秒内向 coordinator 完成最终起跑确认;只有双方都确认后才开始发送,任一方迟到则两边共同终止并将本轮标记为 `INVALID`。两台机器按相同 `run_id` 和确定性调度执行,任何 agent 失联同样判定为 `INVALID`。
`agent` 自身暴露 `/metrics`,提供连接状态、发布、接收、重复、乱序、断连及 Go/process 指标;ACK 和投递延迟分布写入最终 summary。`collect` 只执行只读查询,并把各数据源的原始快照与查询时间一起保存;连接凭证通过环境变量传入,不写入结果目录。 `agent` 自身暴露 `/metrics`,提供连接状态、发布、接收、重复、乱序、断连及 Go/process 指标;ACK 和投递延迟分布写入最终 summary。`collect` 只执行只读查询,并把各数据源的原始快照与查询时间一起保存;连接凭证通过环境变量传入,不写入结果目录。
@@ -0,0 +1,73 @@
# GROUP-2000 R9 双机群聊压测分析与改进
## 1. 结论
`group2k-r9-20261003-01` 不能作为 2000 人群聊容量结论。消息最终落库完整,但实时阶段同时发生机器 B 迟到、Pod 重启和集群 DNS 拒绝查询,导致大量连接断开和跨机器投递缺失。
- 发送尝试 2000 条,数据库精确找到 1774 条服务端已确认消息,缺失、重复、字段不一致均为 0。
- 实时期望投递 3,548,000 次,实际收到 1,384,872 次,到达率约 39.03%。
- 机器 B 比 coordinator 的计划起跑时间晚约 7 分 55 秒;B 未收到任何 A 消息,本轮并非有效的同步双机负载。
- `im-server-1` 在负载中退出,容器退出码 137;现有权限无法读取 Event 和 Pod metrics,尚不能把原因唯一归为 OOM、liveness 或节点压力。
- 三个 Pod 合计约 1715 次依赖域名解析失败,日志包含对集群 DNS `10.43.0.10:53` 的 `connection refused`。
- JPush 设备查询产生 792 次数据库错误;用户更新队列满 1132 次,属于依赖抖动和连接重建后的次生压力。
Kafka 持久化链路本轮表现正确:各 Pod 批量持久化计数合计 1774,与数据库精确核验一致,之前的失败消息保留修复已生效。
## 2. 根因链路
### 2.1 JPush 查询按群消息放大
JPush 使用 registration ID 模式时,每条群消息都会按全部收件人重新查询 `im_device_push_tokens`。2000 人群和 2000 条消息会形成约 400 万个用户查询输入;测试账号没有推送设备,旧实现仍重复查询空结果。JPush 回执又以无限 goroutine 并发处理,使查询、对象分配和内存压力同时放大。
### 2.2 MySQL 连接每 60 秒回收
旧配置的 `conn_max_lifetime=60` 会在持续压测期间频繁重建连接。每次建连都重新解析 `mysql.118im.com`;当集群 DNS 短时拒绝请求时,业务查询、JPush 查询和 Kafka 持久化会一起失败或等待。
### 2.3 双机只有计划时间,没有最终到达屏障
旧协议在双方 READY 后下发统一时间,但 agent 到达该时间后直接发送。机器 B 晚启动时,机器 A 无法知道对方没有真正进入发送阶段,因此形成两段错开的单机流量并生成无效的跨机器统计。
## 3. 已完成改进
### 3.1 JPush
- 设备查询结果按租户、provider、用户缓存 30 秒,包含“无设备”结果。
- 并发首次查询被合并,同一批群成员只访问一次数据库。
- 查询失败后退避 1 秒,已有缓存可在 5 分钟陈旧窗口内继续使用。
- 缓存定期清理,避免用户集合长期增长。
- JPush 回执限制为 8 个工作协程,取消无限 goroutine,队列负责形成背压。
该改动不删除在线多端静默推送语义。新注册或更新的设备最迟约 30 秒进入推送查询结果。
### 3.2 MySQL 与 DNS
- 容器默认 `conn_max_lifetime` 从 60 秒提高到 1800 秒,并支持 `MYSQL_CONN_MAX_LIFETIME_SECONDS` 覆盖。
- 测试环境 GitOps 显式设置 1800 秒,降低持续负载中的连接重建和 DNS 查询频率。
- 保留现有 `ndots:1`、`timeout:2`、`attempts:2`。
这会显著减少应用制造的 DNS 压力,但不能替代集群 DNS 修复。若复测仍出现 `10.43.0.10:53 connection refused`,需要集群管理员检查 CoreDNS 副本、CPU、转发上游、conntrack,并评估与当前 K3s 网络模式匹配的 NodeLocal DNSCache;不能在未知网络模式下直接套用 DaemonSet。
### 3.3 压测同步
- coordinator 增加最终起跑屏障。
- A/B 必须在计划时间前后 3 秒内到达,双方都到达后才开始发送。
- 任一机器迟到时,两边收到同一失败原因并终止本轮。
- coordinator 输出保存双方实际到达时间和 `start_error`。
### 3.4 诊断权限
`backend-debug` 只读权限增加 Event 和 `metrics.k8s.io/pods`,用于下一轮区分 OOM、探针失败、驱逐和节点资源压力。若集群未安装 metrics-server,Pod metrics 仍不可用,但 Event 读取不受影响。
## 4. 复测门禁
下一轮使用新 `run_id`,A/B 必须更新到包含起跑屏障的同一提交,并在应用和 GitOps 配置都发布后执行。
1. coordinator 输出中 A/B `started` 时间相差不超过 3 秒,`start_error` 为空。
2. 2000 条发送全部得到明确 ACK;数据库按 `client_mid` 精确匹配,缺失、重复、字段不一致均为 0。
3. 实时期望 4,000,000 次投递全部到达,重复、乱序和断连均为 0。
4. JPush 设备查询不再随群消息数线性增长,日志中 `jpush: db error` 为 0。
5. Pod 不重启,Event 中无 OOM、Unhealthy、Evicted;负载窗口内持续采集 CPU、内存和重启次数。
6. `10.43.0.10:53`、MySQL、Redis、Kafka 域名解析错误均为 0。
7. Kafka lag 最终归零,数据库最终消息数与 ACK 台账一致。
若业务正确性通过但 DNS 仍有拒绝请求,本轮仍判基础设施门禁失败,不继续用增加应用重试掩盖 DNS 容量问题。
+1 -1
View File
@@ -46,7 +46,7 @@
- 两台机器必须使用 NTP/chrony 同步时间。报告会分别输出同机时钟延迟和跨机器单向延迟;跨机器出现负延迟样本时,后者仅用于诊断,不参与门禁。 - 两台机器必须使用 NTP/chrony 同步时间。报告会分别输出同机时钟延迟和跨机器单向延迟;跨机器出现负延迟样本时,后者仅用于诊断,不参与门禁。
- agent 默认每 20 秒发送 WebSocket Ping,覆盖等待另一台机器、发压、drain 和历史核验阶段;`ws_ping_interval` 必须小于公网入口的空闲连接超时。 - agent 默认每 20 秒发送 WebSocket Ping,覆盖等待另一台机器、发压、drain 和历史核验阶段;`ws_ping_interval` 必须小于公网入口的空闲连接超时。
- 正式开始前 agent 会持续检查实际存活连接;连接断开后自动重新登录并订阅 P2P、群 Topic。初次连接不齐或等待另一台机器期间掉线不会退出。 - 正式开始前 agent 会持续检查实际存活连接;连接断开后自动重新登录并订阅 P2P、群 Topic。初次连接不齐或等待另一台机器期间掉线不会退出。
- coordinator 使用两阶段屏障:两边实时连接数都达到配置值后进入验证倒计时;倒计时内任一方掉线就撤销屏障。两边再次确认满连接后才提交最终开始时间。 - coordinator 使用三阶段屏障:两边实时连接数都达到配置值后进入验证倒计时;倒计时内任一方掉线就撤销屏障;提交最终开始时间后,两边必须在该时间前后 3 秒内再次到达起跑屏障,且双方都到达后才允许发送。任一方迟到会让两边共同终止本轮,避免生成错时压测结果。
- 压测机如保持 OpenVPN 等全局 TUN,仅在另一套代理中设置 `DIRECT` 无法绕过它。起压前必须确认 `http_base` 和 WS 域名的每个当前 IP 都走物理网卡;需保留 VPN 访问内网时,可为这些 IP 配置经局域网关的 `/32` 主机路由。VPN 重连、DNS 变更或机器重启后需重新核验。 - 压测机如保持 OpenVPN 等全局 TUN,仅在另一套代理中设置 `DIRECT` 无法绕过它。起压前必须确认 `http_base` 和 WS 域名的每个当前 IP 都走物理网卡;需保留 VPN 访问内网时,可为这些 IP 配置经局域网关的 `/32` 主机路由。VPN 重连、DNS 变更或机器重启后需重新核验。
## 统计口径 ## 统计口径
+3
View File
@@ -186,6 +186,9 @@ func runAgent(ctx context.Context, cfg *Config, manifest *Manifest, machine, coo
if err = waitUntil(ctx, start.StartAt); err != nil { if err = waitUntil(ctx, start.StartAt); err != nil {
return err return err
} }
if err = coord.started(ctx, machine, start.StartAt); err != nil {
return fmt.Errorf("coordinated start rejected: %w", err)
}
if alive := run.aliveConnections(); alive != len(accounts) { if alive := run.aliveConnections(); alive != len(accounts) {
return fmt.Errorf("connections alive at scheduled start=%d expected=%d", alive, len(accounts)) return fmt.Errorf("connections alive at scheduled start=%d expected=%d", alive, len(accounts))
} }
+92 -9
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -29,6 +30,20 @@ type AgentReady struct {
Armed bool `json:"armed"` Armed bool `json:"armed"`
} }
type AgentStarted struct {
Machine string `json:"machine"`
ScheduledAt time.Time `json:"scheduled_at"`
}
type StartGate struct {
Proceed bool `json:"proceed"`
Error string `json:"error,omitempty"`
ArrivedAgents int `json:"arrived_agents"`
ExpectedAgents int `json:"expected_agents"`
}
const maxAgentStartSkew = 3 * time.Second
type coordinatorState struct { type coordinatorState struct {
mu sync.Mutex mu sync.Mutex
start StartInfo start StartInfo
@@ -37,6 +52,8 @@ type coordinatorState struct {
expectedAgents int expectedAgents int
ready map[string]AgentReady ready map[string]AgentReady
armed map[string]bool armed map[string]bool
started map[string]time.Time
startError string
ledgers map[string][]AcceptedMessage ledgers map[string][]AcceptedMessage
observed map[string][]AcceptedMessage observed map[string][]AcceptedMessage
history map[string][]string history map[string][]string
@@ -60,11 +77,13 @@ func runCoordinator(ctx context.Context, listen, runID, scenario, output string,
expectedAgents: expectedAgents, expectedAgents: expectedAgents,
ready: make(map[string]AgentReady), ledgers: make(map[string][]AcceptedMessage), ready: make(map[string]AgentReady), ledgers: make(map[string][]AcceptedMessage),
observed: make(map[string][]AcceptedMessage), history: make(map[string][]string), observed: make(map[string][]AcceptedMessage), history: make(map[string][]string),
armed: make(map[string]bool), summaries: make(map[string]AgentSummary), complete: make(chan struct{}), armed: make(map[string]bool), started: make(map[string]time.Time),
summaries: make(map[string]AgentSummary), complete: make(chan struct{}),
} }
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/v1/ready", state.handleReady) mux.HandleFunc("/v1/ready", state.handleReady)
mux.HandleFunc("/v1/start", state.handleStart) mux.HandleFunc("/v1/start", state.handleStart)
mux.HandleFunc("/v1/started", state.handleStarted)
mux.HandleFunc("/v1/ledger", state.handleLedger) mux.HandleFunc("/v1/ledger", state.handleLedger)
mux.HandleFunc("/v1/observed", state.handleObserved) mux.HandleFunc("/v1/observed", state.handleObserved)
mux.HandleFunc("/v1/history", state.handleHistory) mux.HandleFunc("/v1/history", state.handleHistory)
@@ -210,11 +229,15 @@ func (s *coordinatorState) handleReady(w http.ResponseWriter, r *http.Request) {
s.start.StartAt = time.Time{} s.start.StartAt = time.Time{}
s.start.Committed = false s.start.Committed = false
clear(s.armed) clear(s.armed)
clear(s.started)
s.startError = ""
} }
if allStable && s.start.StartAt.IsZero() { if allStable && s.start.StartAt.IsZero() {
s.start.StartAt = now.Add(s.startDelay) s.start.StartAt = now.Add(s.startDelay)
s.start.Committed = false s.start.Committed = false
clear(s.armed) clear(s.armed)
clear(s.started)
s.startError = ""
log.Printf("all agents ready; validation_at=%s", s.start.StartAt.Format(time.RFC3339Nano)) log.Printf("all agents ready; validation_at=%s", s.start.StartAt.Format(time.RFC3339Nano))
} }
if allStable && !s.start.Committed && ready.Armed && !now.Before(s.start.StartAt) { if allStable && !s.start.Committed && ready.Armed && !now.Before(s.start.StartAt) {
@@ -234,6 +257,43 @@ func (s *coordinatorState) handleReady(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, start) writeJSON(w, http.StatusOK, start)
} }
func (s *coordinatorState) handleStarted(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var arrival AgentStarted
if !decodeLimitedJSON(w, r, &arrival) || (arrival.Machine != "a" && arrival.Machine != "b") || arrival.ScheduledAt.IsZero() {
http.Error(w, "invalid start arrival", http.StatusBadRequest)
return
}
s.mu.Lock()
defer s.mu.Unlock()
if !s.start.Committed || !arrival.ScheduledAt.Equal(s.start.StartAt) {
writeJSON(w, http.StatusOK, StartGate{Error: "start schedule changed", ExpectedAgents: s.expectedAgents})
return
}
if s.started == nil {
s.started = make(map[string]time.Time)
}
if _, exists := s.started[arrival.Machine]; !exists {
now := time.Now().UTC()
s.started[arrival.Machine] = now
skew := now.Sub(s.start.StartAt)
if skew < -maxAgentStartSkew || skew > maxAgentStartSkew {
s.startError = fmt.Sprintf("agent %s reached start barrier %s from schedule (limit %s)",
arrival.Machine, skew.Round(time.Millisecond), maxAgentStartSkew)
log.Printf("start barrier rejected: %s", s.startError)
}
}
gate := StartGate{
Proceed: len(s.started) == s.expectedAgents && s.startError == "", Error: s.startError,
ArrivedAgents: len(s.started), ExpectedAgents: s.expectedAgents,
}
writeJSON(w, http.StatusOK, gate)
}
func (s *coordinatorState) handleStart(w http.ResponseWriter, r *http.Request) { func (s *coordinatorState) handleStart(w http.ResponseWriter, r *http.Request) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -311,7 +371,8 @@ func (s *coordinatorState) handleStatus(w http.ResponseWriter, _ *http.Request)
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"start": s.start, "ready": s.ready, "armed": s.armed, "start": s.start, "ready": s.ready, "armed": s.armed, "started": s.started,
"start_error": s.startError,
"ledger_agents": len(s.ledgers), "observed_agents": len(s.observed), "ledger_agents": len(s.ledgers), "observed_agents": len(s.observed),
"history_agents": len(s.history), "summary_agents": len(s.summaries), "history_agents": len(s.history), "summary_agents": len(s.summaries),
}) })
@@ -320,14 +381,17 @@ func (s *coordinatorState) handleStatus(w http.ResponseWriter, _ *http.Request)
func (s *coordinatorState) writeOutput(path string) error { func (s *coordinatorState) writeOutput(path string) error {
s.mu.Lock() s.mu.Lock()
data := struct { data := struct {
Start StartInfo `json:"start"` Start StartInfo `json:"start"`
Ready map[string]AgentReady `json:"ready"` Ready map[string]AgentReady `json:"ready"`
Ledgers map[string][]AcceptedMessage `json:"ledgers"` Started map[string]time.Time `json:"started"`
Observed map[string][]AcceptedMessage `json:"observed"` StartError string `json:"start_error,omitempty"`
History map[string][]string `json:"history"` Ledgers map[string][]AcceptedMessage `json:"ledgers"`
Summaries map[string]AgentSummary `json:"summaries"` Observed map[string][]AcceptedMessage `json:"observed"`
History map[string][]string `json:"history"`
Summaries map[string]AgentSummary `json:"summaries"`
}{ }{
Start: s.start, Ready: s.ready, Ledgers: s.ledgers, Observed: s.observed, Start: s.start, Ready: s.ready, Started: s.started, StartError: s.startError,
Ledgers: s.ledgers, Observed: s.observed,
History: s.history, Summaries: s.summaries, History: s.history, Summaries: s.summaries,
} }
s.mu.Unlock() s.mu.Unlock()
@@ -403,6 +467,25 @@ func (c *coordinatorClient) submitLedger(ctx context.Context, machine string, me
return err return err
} }
func (c *coordinatorClient) started(ctx context.Context, machine string, scheduledAt time.Time) error {
arrival := AgentStarted{Machine: machine, ScheduledAt: scheduledAt}
for {
var gate StartGate
if _, err := c.request(ctx, http.MethodPost, "/v1/started", arrival, &gate); err != nil {
return err
}
if gate.Error != "" {
return errors.New(gate.Error)
}
if gate.Proceed {
return nil
}
if err := waitContext(ctx, 100*time.Millisecond); err != nil {
return err
}
}
}
func (c *coordinatorClient) ledger(ctx context.Context) ([]AcceptedMessage, error) { func (c *coordinatorClient) ledger(ctx context.Context) ([]AcceptedMessage, error) {
for { for {
var messages []AcceptedMessage var messages []AcceptedMessage
+50
View File
@@ -15,6 +15,7 @@ func TestCoordinatorResetsBarrierWhenConnectionCountDrops(t *testing.T) {
start: StartInfo{RunID: "run-reset", Scenario: "group"}, startDelay: time.Minute, start: StartInfo{RunID: "run-reset", Scenario: "group"}, startDelay: time.Minute,
commitDelay: time.Second, expectedAgents: 2, ready: make(map[string]AgentReady), commitDelay: time.Second, expectedAgents: 2, ready: make(map[string]AgentReady),
armed: make(map[string]bool), ledgers: make(map[string][]AcceptedMessage), armed: make(map[string]bool), ledgers: make(map[string][]AcceptedMessage),
started: make(map[string]time.Time),
summaries: make(map[string]AgentSummary), complete: make(chan struct{}), summaries: make(map[string]AgentSummary), complete: make(chan struct{}),
} }
postReady := func(ready AgentReady) StartInfo { postReady := func(ready AgentReady) StartInfo {
@@ -52,6 +53,7 @@ func TestCoordinatorClientWaitsForBothAgentsAndMergesLedger(t *testing.T) {
commitDelay: 50 * time.Millisecond, expectedAgents: 2, commitDelay: 50 * time.Millisecond, expectedAgents: 2,
ready: make(map[string]AgentReady), ledgers: make(map[string][]AcceptedMessage), ready: make(map[string]AgentReady), ledgers: make(map[string][]AcceptedMessage),
armed: make(map[string]bool), summaries: make(map[string]AgentSummary), complete: make(chan struct{}), armed: make(map[string]bool), summaries: make(map[string]AgentSummary), complete: make(chan struct{}),
started: make(map[string]time.Time),
} }
server := httptest.NewServer(testCoordinatorMux(state)) server := httptest.NewServer(testCoordinatorMux(state))
defer server.Close() defer server.Close()
@@ -116,6 +118,7 @@ func testCoordinatorMux(state *coordinatorState) *http.ServeMux {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/v1/ready", state.handleReady) mux.HandleFunc("/v1/ready", state.handleReady)
mux.HandleFunc("/v1/start", state.handleStart) mux.HandleFunc("/v1/start", state.handleStart)
mux.HandleFunc("/v1/started", state.handleStarted)
mux.HandleFunc("/v1/ledger", state.handleLedger) mux.HandleFunc("/v1/ledger", state.handleLedger)
mux.HandleFunc("/v1/observed", state.handleObserved) mux.HandleFunc("/v1/observed", state.handleObserved)
mux.HandleFunc("/v1/history", state.handleHistory) mux.HandleFunc("/v1/history", state.handleHistory)
@@ -123,3 +126,50 @@ func testCoordinatorMux(state *coordinatorState) *http.ServeMux {
mux.HandleFunc("/v1/status", state.handleStatus) mux.HandleFunc("/v1/status", state.handleStatus)
return mux return mux
} }
func TestCoordinatorRejectsLateAgentForBothMachines(t *testing.T) {
startAt := time.Now().Add(-maxAgentStartSkew - time.Second).UTC()
state := &coordinatorState{
start: StartInfo{RunID: "run-late", Scenario: "group", StartAt: startAt, Committed: true},
expectedAgents: 2, started: make(map[string]time.Time),
}
server := httptest.NewServer(testCoordinatorMux(state))
defer server.Close()
client := newCoordinatorClient(server.URL)
if err := client.started(context.Background(), "b", startAt); err == nil {
t.Fatal("late agent must be rejected")
}
if err := client.started(context.Background(), "a", startAt); err == nil {
t.Fatal("the other agent must observe the same rejected run")
}
}
func TestCoordinatorReleasesAgentsOnlyAfterBothReachStart(t *testing.T) {
startAt := time.Now().UTC()
state := &coordinatorState{
start: StartInfo{RunID: "run-synced", Scenario: "group", StartAt: startAt, Committed: true},
expectedAgents: 2, started: make(map[string]time.Time),
}
server := httptest.NewServer(testCoordinatorMux(state))
defer server.Close()
client := newCoordinatorClient(server.URL)
aDone := make(chan error, 1)
go func() { aDone <- client.started(context.Background(), "a", startAt) }()
time.Sleep(50 * time.Millisecond)
select {
case err := <-aDone:
t.Fatalf("machine A passed before B arrived: %v", err)
default:
}
if err := client.started(context.Background(), "b", startAt); err != nil {
t.Fatalf("machine B start: %v", err)
}
select {
case err := <-aDone:
if err != nil {
t.Fatalf("machine A start: %v", err)
}
case <-time.After(time.Second):
t.Fatal("machine A was not released")
}
}
+3 -1
View File
@@ -89,7 +89,7 @@
"max_idle_conns": 8, "max_idle_conns": 8,
"registry_max_open_conns": 4, "registry_max_open_conns": 4,
"registry_max_idle_conns": 2, "registry_max_idle_conns": 2,
"conn_max_lifetime": 60, "conn_max_lifetime": 1800,
"sql_timeout": 10 "sql_timeout": 10
} }
}, },
@@ -229,6 +229,8 @@
"enabled": false, "enabled": false,
"api_domain": "https://api.jpush.cn", "api_domain": "https://api.jpush.cn",
"push_path": "/v3/push", "push_path": "/v3/push",
"workers": 8,
"device_cache_ttl_seconds": 30,
"default_ttl_seconds": 86400, "default_ttl_seconds": 86400,
"call_ttl_seconds": 60, "call_ttl_seconds": 60,
"apns_production": false, "apns_production": false,
+164 -21
View File
@@ -12,6 +12,7 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/logs"
@@ -21,16 +22,39 @@ import (
) )
const ( const (
defaultBufferSize = 1024 defaultBufferSize = 1024
defaultAPIDomain = "https://api.jpush.cn" defaultWorkers = 8
defaultPushPath = "/v3/push" defaultAPIDomain = "https://api.jpush.cn"
defaultTTL = 86400 defaultPushPath = "/v3/push"
defaultCallTTL = 30 defaultTTL = 86400
callCategory = "im_call" defaultCallTTL = 30
defaultDeviceCacheTTL = 30
callCategory = "im_call"
) )
var handler Handler 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 { type Handler struct {
input chan *push.Receipt input chan *push.Receipt
channel chan *push.ChannelReq channel chan *push.ChannelReq
@@ -40,19 +64,21 @@ type Handler struct {
} }
type configType struct { type configType struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
DryRun bool `json:"dry_run"` DryRun bool `json:"dry_run"`
AppKey string `json:"app_key"` AppKey string `json:"app_key"`
MasterSecret string `json:"master_secret"` MasterSecret string `json:"master_secret"`
APIDomain string `json:"api_domain"` APIDomain string `json:"api_domain"`
PushPath string `json:"push_path"` PushPath string `json:"push_path"`
Buffer int `json:"buffer"` Buffer int `json:"buffer"`
DefaultTTLSeconds int `json:"default_ttl_seconds"` Workers int `json:"workers"`
CallTTLSeconds int `json:"call_ttl_seconds"` DefaultTTLSeconds int `json:"default_ttl_seconds"`
APNsProduction bool `json:"apns_production"` CallTTLSeconds int `json:"call_ttl_seconds"`
UseAlias *bool `json:"use_alias"` DeviceCacheTTLSeconds int `json:"device_cache_ttl_seconds"`
Classification int `json:"classification"` APNsProduction bool `json:"apns_production"`
Title string `json:"title"` UseAlias *bool `json:"use_alias"`
Classification int `json:"classification"`
Title string `json:"title"`
} }
type jpushPayload struct { type jpushPayload struct {
@@ -154,10 +180,19 @@ func (Handler) Init(jsonconf json.RawMessage) (bool, error) {
handler.config = normalized handler.config = normalized
go func() { go func() {
workers := make(chan struct{}, normalized.Workers)
for { for {
select { select {
case rcpt := <-handler.input: 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: case req := <-handler.channel:
logs.Info.Println("jpush: channel subscription ignored", req.Uid.UserId(), req.Channel) logs.Info.Println("jpush: channel subscription ignored", req.Uid.UserId(), req.Channel)
case <-handler.stop: case <-handler.stop:
@@ -186,12 +221,18 @@ func normalizeConfig(config configType) (configType, error) {
if config.Buffer <= 0 { if config.Buffer <= 0 {
config.Buffer = defaultBufferSize config.Buffer = defaultBufferSize
} }
if config.Workers <= 0 {
config.Workers = defaultWorkers
}
if config.DefaultTTLSeconds <= 0 { if config.DefaultTTLSeconds <= 0 {
config.DefaultTTLSeconds = defaultTTL config.DefaultTTLSeconds = defaultTTL
} }
if config.CallTTLSeconds <= 0 { if config.CallTTLSeconds <= 0 {
config.CallTTLSeconds = defaultCallTTL config.CallTTLSeconds = defaultCallTTL
} }
if config.DeviceCacheTTLSeconds <= 0 {
config.DeviceCacheTTLSeconds = defaultDeviceCacheTTL
}
if config.Title == "" { if config.Title == "" {
config.Title = "MeetingIM" config.Title = "MeetingIM"
} }
@@ -260,6 +301,8 @@ func loadThirdPartyConfig(config *configType) {
"jpush_master_secret", "jpush_master_secret",
"jpush_api_domain", "jpush_api_domain",
"jpush_push_path", "jpush_push_path",
"jpush_workers",
"jpush_device_cache_ttl_seconds",
"jpush_default_ttl_seconds", "jpush_default_ttl_seconds",
"jpush_call_ttl_seconds", "jpush_call_ttl_seconds",
"jpush_apns_production", "jpush_apns_production",
@@ -285,6 +328,12 @@ func loadThirdPartyConfig(config *configType) {
if v := strings.TrimSpace(values["jpush_push_path"]); v != "" { if v := strings.TrimSpace(values["jpush_push_path"]); v != "" {
config.PushPath = 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 { if v, ok := parseConfigInt(values["jpush_default_ttl_seconds"]); ok {
config.DefaultTTLSeconds = v config.DefaultTTLSeconds = v
} }
@@ -344,7 +393,8 @@ func preparePayloads(rcpt *push.Receipt, config *configType) ([]*jpushPayload, [
} }
var devices map[t.Uid][]t.DeviceDef var devices map[t.Uid][]t.DeviceDef
if !useAlias { 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 { if err != nil {
logs.Warn.Println("jpush: db error", err) logs.Warn.Println("jpush: db error", err)
return nil, nil 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 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 { func (Handler) IsReady() bool {
return handler.input != nil return handler.input != nil
} }
+78 -12
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync"
"testing" "testing"
"time" "time"
@@ -27,32 +28,39 @@ func (s testThirdPartyConfigStore) Get(_ string, keys ...string) (map[string]str
type testDevicePushTokenStore struct { type testDevicePushTokenStore struct {
devices map[types.Uid][]types.DeviceDef devices map[types.Uid][]types.DeviceDef
calls int
} }
func (s *testDevicePushTokenStore) Upsert(types.TenantID, *types.DevicePushToken) (*types.DevicePushToken, error) { func (s *testDevicePushTokenStore) Upsert(types.TenantID, *types.DevicePushToken) (*types.DevicePushToken, error) {
return nil, nil 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 count := 0
for _, devices := range s.devices { for _, uid := range uids {
devices := s.devices[uid]
result[uid] = devices
count += len(devices) count += len(devices)
} }
return s.devices, count, nil return result, count, nil
} }
func TestNormalizeConfigLoadsThirdPartyConfig(t *testing.T) { func TestNormalizeConfigLoadsThirdPartyConfig(t *testing.T) {
oldConfig := store.ThirdPartyConfig oldConfig := store.ThirdPartyConfig
store.ThirdPartyConfig = testThirdPartyConfigStore{ store.ThirdPartyConfig = testThirdPartyConfigStore{
"jpush_enabled": "1", "jpush_enabled": "1",
"jpush_app_key": "app-key", "jpush_app_key": "app-key",
"jpush_master_secret": "master", "jpush_master_secret": "master",
"jpush_api_domain": "https://push.example.com/", "jpush_api_domain": "https://push.example.com/",
"jpush_push_path": "v3/push", "jpush_push_path": "v3/push",
"jpush_default_ttl_seconds": "123", "jpush_workers": "4",
"jpush_call_ttl_seconds": "45", "jpush_device_cache_ttl_seconds": "45",
"jpush_use_alias": "false", "jpush_default_ttl_seconds": "123",
"jpush_classification": "1", "jpush_call_ttl_seconds": "45",
"jpush_use_alias": "false",
"jpush_classification": "1",
} }
defer func() { store.ThirdPartyConfig = oldConfig }() 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" { if config.APIDomain != "https://push.example.com" || config.PushPath != "/v3/push" {
t.Fatalf("unexpected endpoint: %#v", config) 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 { if config.DefaultTTLSeconds != 123 || config.CallTTLSeconds != 45 || config.UseAlias == nil || *config.UseAlias {
t.Fatalf("unexpected ttl/use_alias: %#v", config) t.Fatalf("unexpected ttl/use_alias: %#v", config)
} }
@@ -119,6 +130,8 @@ func TestAliasForUidUsesJPushSafeCharacters(t *testing.T) {
} }
func TestPreparePayloadsCanUseRegistrationID(t *testing.T) { func TestPreparePayloadsCanUseRegistrationID(t *testing.T) {
resetDeviceCache()
t.Cleanup(resetDeviceCache)
oldTokens := store.DevicePushTokens oldTokens := store.DevicePushTokens
uid := types.Uid(42) uid := types.Uid(42)
store.DevicePushTokens = &testDevicePushTokenStore{devices: map[types.Uid][]types.DeviceDef{ store.DevicePushTokens = &testDevicePushTokenStore{devices: map[types.Uid][]types.DeviceDef{
@@ -147,6 +160,8 @@ func TestPreparePayloadsCanUseRegistrationID(t *testing.T) {
} }
func TestPreparePayloadsDoesNotDropRecipientWithDeliveredSession(t *testing.T) { func TestPreparePayloadsDoesNotDropRecipientWithDeliveredSession(t *testing.T) {
resetDeviceCache()
t.Cleanup(resetDeviceCache)
oldTokens := store.DevicePushTokens oldTokens := store.DevicePushTokens
uid := types.Uid(42) uid := types.Uid(42)
store.DevicePushTokens = &testDevicePushTokenStore{devices: map[types.Uid][]types.DeviceDef{ 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) { func TestBuildPayloadUsesShortTTLForCalls(t *testing.T) {
expiresAt := time.Now().Add(30 * time.Second).UTC().Format(time.RFC3339Nano) expiresAt := time.Now().Add(30 * time.Second).UTC().Format(time.RFC3339Nano)
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60} config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60}
+3 -1
View File
@@ -88,7 +88,7 @@
"max_idle_conns": 8, "max_idle_conns": 8,
"registry_max_open_conns": 4, "registry_max_open_conns": 4,
"registry_max_idle_conns": 2, "registry_max_idle_conns": 2,
"conn_max_lifetime": 60, "conn_max_lifetime": 1800,
"sql_timeout": 10 "sql_timeout": 10
} }
}, },
@@ -228,6 +228,8 @@
"enabled": false, "enabled": false,
"api_domain": "https://api.jpush.cn", "api_domain": "https://api.jpush.cn",
"push_path": "/v3/push", "push_path": "/v3/push",
"workers": 8,
"device_cache_ttl_seconds": 30,
"default_ttl_seconds": 86400, "default_ttl_seconds": 86400,
"call_ttl_seconds": 60, "call_ttl_seconds": 60,
"apns_production": false, "apns_production": false,