fix: protect publish path from mysql exhaustion
This commit is contained in:
@@ -41,7 +41,11 @@ jobs:
|
||||
CLUSTER_PROXY_TO_MASTER_WORKERS=16 \
|
||||
CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS=300 \
|
||||
CLUSTER_ROUTE_RETRIES=3 \
|
||||
CLUSTER_ROUTE_RETRY_BACKOFF_MS=25
|
||||
CLUSTER_ROUTE_RETRY_BACKOFF_MS=25 \
|
||||
MYSQL_MAX_OPEN_CONNS=24 \
|
||||
MYSQL_MAX_IDLE_CONNS=8 \
|
||||
MYSQL_REGISTRY_MAX_OPEN_CONNS=4 \
|
||||
MYSQL_REGISTRY_MAX_IDLE_CONNS=2
|
||||
kubectl patch statefulset/im-server -n im-server-system --type=strategic -p \
|
||||
'{"spec":{"template":{"spec":{"containers":[{"name":"im-server","readinessProbe":{"httpGet":{"path":"/healthz","port":6060},"timeoutSeconds":3,"periodSeconds":10,"failureThreshold":3},"livenessProbe":{"httpGet":{"path":"/livez","port":6060},"timeoutSeconds":3,"periodSeconds":20,"failureThreshold":3}}]}}}}'
|
||||
if kubectl auth can-i get services -n im-server-system >/dev/null 2>&1 && \
|
||||
@@ -74,10 +78,20 @@ jobs:
|
||||
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.limits.memory}')"
|
||||
liveness="$(kubectl get statefulset/im-server -n im-server-system -o \
|
||||
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].livenessProbe.httpGet.path}')"
|
||||
echo "effective requests=${request_cpu}/${request_memory} limits=${limit_cpu}/${limit_memory} liveness=${liveness}"
|
||||
mysql_max_open="$(kubectl get statefulset/im-server -n im-server-system -o \
|
||||
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_MAX_OPEN_CONNS")].value}')"
|
||||
mysql_max_idle="$(kubectl get statefulset/im-server -n im-server-system -o \
|
||||
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_MAX_IDLE_CONNS")].value}')"
|
||||
mysql_registry_max_open="$(kubectl get statefulset/im-server -n im-server-system -o \
|
||||
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_REGISTRY_MAX_OPEN_CONNS")].value}')"
|
||||
mysql_registry_max_idle="$(kubectl get statefulset/im-server -n im-server-system -o \
|
||||
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_REGISTRY_MAX_IDLE_CONNS")].value}')"
|
||||
echo "effective requests=${request_cpu}/${request_memory} limits=${limit_cpu}/${limit_memory} liveness=${liveness} mysql=${mysql_max_open}/${mysql_max_idle} registry=${mysql_registry_max_open}/${mysql_registry_max_idle}"
|
||||
if [ "${request_cpu}" != "1" ] || [ "${request_memory}" != "512Mi" ] || \
|
||||
[ "${limit_cpu}" != "2" ] || [ "${limit_memory}" != "1Gi" ] || \
|
||||
[ "${liveness}" != "/livez" ]; then
|
||||
echo "::error::ArgoCD reverted StatefulSet resources/probes. Apply deploy/argocd/im-server-test/statefulset-performance-patch.yaml to the im-server-test source."
|
||||
[ "${liveness}" != "/livez" ] || [ "${mysql_max_open}" != "24" ] || \
|
||||
[ "${mysql_max_idle}" != "8" ] || [ "${mysql_registry_max_open}" != "4" ] || \
|
||||
[ "${mysql_registry_max_idle}" != "2" ]; then
|
||||
echo "::error::ArgoCD reverted StatefulSet resources/probes/MySQL pools. Apply deploy/argocd/im-server-test/statefulset-performance-patch.yaml to the im-server-test source."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -14,8 +14,12 @@ After ArgoCD sync, verify the effective configuration:
|
||||
```bash
|
||||
kubectl -n im-server-system get statefulset im-server -o jsonpath='{.spec.template.spec.containers[0].resources}'
|
||||
kubectl -n im-server-system get statefulset im-server -o jsonpath='{.spec.template.spec.containers[0].livenessProbe.httpGet.path}'
|
||||
kubectl -n im-server-system get statefulset im-server -o jsonpath='{.spec.template.spec.containers[0].env}'
|
||||
```
|
||||
|
||||
Expected values are requests `1 CPU / 512Mi`, limits `2 CPU / 1Gi`, and
|
||||
liveness path `/livez`. The application repository's deploy workflow waits
|
||||
for reconciliation and fails if ArgoCD restores different values.
|
||||
liveness path `/livez`. The MySQL pool environment must be
|
||||
`MYSQL_MAX_OPEN_CONNS=24`, `MYSQL_MAX_IDLE_CONNS=8`,
|
||||
`MYSQL_REGISTRY_MAX_OPEN_CONNS=4`, and `MYSQL_REGISTRY_MAX_IDLE_CONNS=2`.
|
||||
The application repository's deploy workflow waits for reconciliation and
|
||||
fails if ArgoCD restores different values.
|
||||
|
||||
@@ -19,6 +19,14 @@ spec:
|
||||
value: "3"
|
||||
- name: CLUSTER_ROUTE_RETRY_BACKOFF_MS
|
||||
value: "25"
|
||||
- name: MYSQL_MAX_OPEN_CONNS
|
||||
value: "24"
|
||||
- name: MYSQL_MAX_IDLE_CONNS
|
||||
value: "8"
|
||||
- name: MYSQL_REGISTRY_MAX_OPEN_CONNS
|
||||
value: "4"
|
||||
- name: MYSQL_REGISTRY_MAX_IDLE_CONNS
|
||||
value: "2"
|
||||
resources:
|
||||
requests:
|
||||
cpu: "1"
|
||||
|
||||
@@ -79,8 +79,10 @@
|
||||
"mysql": {
|
||||
"database": "meeting_im",
|
||||
"dsn": "$MYSQL_DSN",
|
||||
"max_open_conns": 64,
|
||||
"max_idle_conns": 64,
|
||||
"max_open_conns": $MYSQL_MAX_OPEN_CONNS,
|
||||
"max_idle_conns": $MYSQL_MAX_IDLE_CONNS,
|
||||
"registry_max_open_conns": $MYSQL_REGISTRY_MAX_OPEN_CONNS,
|
||||
"registry_max_idle_conns": $MYSQL_REGISTRY_MAX_IDLE_CONNS,
|
||||
"conn_max_lifetime": 60,
|
||||
"sql_timeout": 10,
|
||||
"tenant_registry_db": "$TENANT_REGISTRY_DB",
|
||||
|
||||
@@ -18,6 +18,10 @@ else
|
||||
: "${CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS:=300}"
|
||||
: "${CLUSTER_ROUTE_RETRIES:=3}"
|
||||
: "${CLUSTER_ROUTE_RETRY_BACKOFF_MS:=25}"
|
||||
: "${MYSQL_MAX_OPEN_CONNS:=24}"
|
||||
: "${MYSQL_MAX_IDLE_CONNS:=8}"
|
||||
: "${MYSQL_REGISTRY_MAX_OPEN_CONNS:=4}"
|
||||
: "${MYSQL_REGISTRY_MAX_IDLE_CONNS:=2}"
|
||||
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"}]'
|
||||
fi
|
||||
|
||||
@@ -414,3 +414,23 @@ Run ID:`p2p-500-20260927-01`。A/B 各建立 500 条 WebSocket,共 500 对
|
||||
2. 修正 IM Server 每 Pod 的 `max_open_conns/max_idle_conns`,或在验证 MySQL CPU、内存、线程和 PXC 限制后提高 `max_connections`;必须保留至少 20% 全局余量。
|
||||
3. 压测中增加 DB pool `OpenConnections/InUse/Idle/WaitCount/WaitDuration`和 MySQL `Threads_connected/Threads_running/Max_used_connections/Connection_errors_max_connections` 指标。
|
||||
4. 复测要求 Publish ACK 10000/10000、实时丢失和断连为 0、Kafka 60 秒内归零、MySQL 1040 和清理期 queue full 为 0,然后才能进入 1000 对。
|
||||
|
||||
### 15.1 为什么 Kafka 可以积压,但本轮仍会报错
|
||||
|
||||
Kafka 积压只能保护“已经成功写入 Kafka”的消息。正常情况下,consumer 落库变慢只会使 lag 上升,消息会在 Kafka 中等待,不应导致 Publish 报错。本轮的 54 条失败发生在 Kafka 之前:
|
||||
|
||||
1. 普通文本消息先同步查询青少年模式设置,但当前规则无论查询结果都允许消息继续进入内容审核。
|
||||
2. P2P 发布在写 Kafka 前调用 `CanSendP2P`,旧实现即使 Redis 已命中好友关系,仍会读取 MySQL 用户和好友记录。
|
||||
3. 入口校验、Kafka consumer、历史查询和登录审计共用 MySQL 连接预算。连接耗尽后,入口校验直接返回 500,这些消息没有进入 Kafka,因此无法通过“后续慢慢消费”恢复。
|
||||
|
||||
### 15.2 IM 侧改造
|
||||
|
||||
2026-09-27 已完成以下修复:
|
||||
|
||||
- 普通消息不再读取青少年模式表;通话的夜间禁用规则仍保留数据库查询。
|
||||
- P2P 热路径先查双向黑名单缓存,再使用好友正缓存;缓存未命中或 Redis 异常时才回源 MySQL。新增拉黑会同时清理双向好友正缓存,避免安全状态陈旧。
|
||||
- `meeting_im` 每 Pod 连接池设为 `max_open=24/max_idle=8`,`hotgo` 注册表库独立设为 `max_open=4/max_idle=2`。3 个 Pod 的 IM 理论总上限由 384 降为 84;结合 Workbench 30、Manager 20 的上限,整套服务理论上限为 134,在当前 MySQL 170 连接上限下保留 36 条全局余量。
|
||||
- 连接池参数改为环境变量,并同时固化到应用仓库及 ArgoCD 真实配置源。发布流水线会在 ArgoCD 回写后校验四个连接池参数。
|
||||
- 新增 `im_db_pool_*` 指标,包括 max/open/in-use/idle/wait count/wait duration/关闭计数,并为连接池 85%/95% 使用率配置预警和严重告警。
|
||||
|
||||
这些修复的目标是先让入口稳定完成缓存校验和 Kafka enqueue,把 MySQL 慢转化为可观测、可恢复的 Kafka lag,而不是 Publish 500。如果复测时 `im_db_pool_wait_total` 持续增长或 Kafka 仍无法在 60 秒内归零,再根据 PXC 的 CPU/内存/线程容量将 MySQL `max_connections` 持久化提高到 300–400,不使用临时 `SET GLOBAL`代替配置源。
|
||||
|
||||
@@ -49,6 +49,20 @@ spec:
|
||||
summary: "IM WebSocket connections are imbalanced across pods"
|
||||
- name: im-server-persistence
|
||||
rules:
|
||||
- alert: IMDatabasePoolHigh
|
||||
expr: im_db_pool_in_use_connections / clamp_min(im_db_pool_max_open_connections, 1) > 0.85
|
||||
for: 1m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "IM database pool usage is above 85%"
|
||||
- alert: IMDatabasePoolCritical
|
||||
expr: im_db_pool_in_use_connections / clamp_min(im_db_pool_max_open_connections, 1) > 0.95
|
||||
for: 30s
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "IM database pool usage is above 95%"
|
||||
- alert: IMKafkaPersistDeadLetter
|
||||
expr: sum by (kind, reason) (increase(im_kafka_dead_letters_total[5m])) > 0
|
||||
labels:
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/tinode/chat/server/auth"
|
||||
dbadapter "github.com/tinode/chat/server/db"
|
||||
"github.com/tinode/chat/server/db/common"
|
||||
immetrics "github.com/tinode/chat/server/metrics"
|
||||
"github.com/tinode/chat/server/store"
|
||||
t "github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
@@ -109,6 +110,9 @@ type configType struct {
|
||||
MaxOpenConns int `json:"max_open_conns,omitempty"`
|
||||
// Maximum number of connections in the idle connection pool.
|
||||
MaxIdleConns int `json:"max_idle_conns,omitempty"`
|
||||
// Registry database has a separate, much smaller workload and pool budget.
|
||||
RegistryMaxOpenConns int `json:"registry_max_open_conns,omitempty"`
|
||||
RegistryMaxIdleConns int `json:"registry_max_idle_conns,omitempty"`
|
||||
// Maximum amount of time a connection may be reused (in seconds).
|
||||
ConnMaxLifetime int `json:"conn_max_lifetime,omitempty"`
|
||||
|
||||
@@ -185,6 +189,7 @@ func openMySQLDB(config configType, dsn, dbName string) (*sqlx.DB, string, error
|
||||
if config.ConnMaxLifetime > 0 {
|
||||
db.SetConnMaxLifetime(time.Duration(config.ConnMaxLifetime) * time.Second)
|
||||
}
|
||||
immetrics.RegisterDBPool(dbName, db.DB)
|
||||
return db, dbName, nil
|
||||
}
|
||||
|
||||
@@ -281,7 +286,14 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
|
||||
a.txTimeout = time.Duration(float64(config.SqlTimeout)*txTimeoutMultiplier) * time.Second
|
||||
}
|
||||
if a.tenantRegistryDB != "" && a.tenantRegistryDB != a.dbName {
|
||||
a.registryDB, _, err = openMySQLDB(config, a.dsn, a.tenantRegistryDB)
|
||||
registryConfig := config
|
||||
if config.RegistryMaxOpenConns > 0 {
|
||||
registryConfig.MaxOpenConns = config.RegistryMaxOpenConns
|
||||
}
|
||||
if config.RegistryMaxIdleConns > 0 {
|
||||
registryConfig.MaxIdleConns = config.RegistryMaxIdleConns
|
||||
}
|
||||
a.registryDB, _, err = openMySQLDB(registryConfig, a.dsn, a.tenantRegistryDB)
|
||||
if err == nil {
|
||||
err = a.registryDB.Ping()
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ func imYouthModePolicyDecision(tenantID types.TenantID, actor types.Uid, action
|
||||
if store.Security == nil || actor.IsZero() {
|
||||
return imAllowDecision()
|
||||
}
|
||||
// Message content filtering is enforced by moderation below the policy
|
||||
// check. Youth mode currently has no message-level deny rule, so avoid a
|
||||
// database lookup on every ordinary publish.
|
||||
if strings.TrimSpace(action) == imYouthActionMessage {
|
||||
return imAllowDecision()
|
||||
}
|
||||
settings, err := store.Security.GetYouthMode(tenantID, actor)
|
||||
if err != nil {
|
||||
if !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
|
||||
@@ -65,10 +71,6 @@ func imYouthModePolicyDecision(tenantID types.TenantID, actor types.Uid, action
|
||||
"policy_version": settings.PolicyVersion,
|
||||
})
|
||||
}
|
||||
case imYouthActionMessage:
|
||||
// content_filter is enforced by routing messages through content moderation
|
||||
// before persistence; the decision remains allow unless moderation rejects it.
|
||||
return imAllowDecision()
|
||||
}
|
||||
return imAllowDecision()
|
||||
}
|
||||
|
||||
@@ -33,3 +33,18 @@ func TestYouthModeNightQuietRestrictsCalls(t *testing.T) {
|
||||
t.Fatalf("daytime call should be allowed: %#v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouthModeOrdinaryMessageDoesNotReadDatabase(t *testing.T) {
|
||||
oldSecurity := store.Security
|
||||
security := &testSecurityStore{err: types.ErrInternal}
|
||||
store.Security = security
|
||||
defer func() { store.Security = oldSecurity }()
|
||||
|
||||
decision := imYouthModePolicyDecision(7, types.Uid(42), imYouthActionMessage, time.Now())
|
||||
if !decision.Allowed {
|
||||
t.Fatalf("ordinary message should continue to moderation: %#v", decision)
|
||||
}
|
||||
if security.youthModeReads != 0 {
|
||||
t.Fatalf("ordinary message read youth settings %d times", security.youthModeReads)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,8 +84,10 @@
|
||||
"dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(mysql.118im.com:30306)/meeting_im?parseTime=true&collation=utf8mb4_0900_ai_ci",
|
||||
"tenant_registry_db": "hotgo",
|
||||
"tenant_db_routing": true,
|
||||
"max_open_conns": 64,
|
||||
"max_idle_conns": 64,
|
||||
"max_open_conns": 24,
|
||||
"max_idle_conns": 8,
|
||||
"registry_max_open_conns": 4,
|
||||
"registry_max_idle_conns": 2,
|
||||
"conn_max_lifetime": 60,
|
||||
"sql_timeout": 10
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
type dbStatsProvider interface {
|
||||
Stats() sql.DBStats
|
||||
}
|
||||
|
||||
type dbPoolCollector struct {
|
||||
pools sync.Map
|
||||
desc map[string]*prometheus.Desc
|
||||
}
|
||||
|
||||
func newDBPoolCollector() *dbPoolCollector {
|
||||
labels := []string{"pod", "pool"}
|
||||
return &dbPoolCollector{desc: map[string]*prometheus.Desc{
|
||||
"max_open": prometheus.NewDesc("im_db_pool_max_open_connections", "Configured maximum open database connections.", labels, nil),
|
||||
"open": prometheus.NewDesc("im_db_pool_open_connections", "Current open database connections.", labels, nil),
|
||||
"in_use": prometheus.NewDesc("im_db_pool_in_use_connections", "Current database connections in use.", labels, nil),
|
||||
"idle": prometheus.NewDesc("im_db_pool_idle_connections", "Current idle database connections.", labels, nil),
|
||||
"wait": prometheus.NewDesc("im_db_pool_wait_total", "Total waits for a database connection.", labels, nil),
|
||||
"wait_duration": prometheus.NewDesc("im_db_pool_wait_duration_seconds_total", "Total time spent waiting for database connections.", labels, nil),
|
||||
"max_idle_closed": prometheus.NewDesc("im_db_pool_max_idle_closed_total", "Connections closed because the idle limit was exceeded.", labels, nil),
|
||||
"max_lifetime_closed": prometheus.NewDesc("im_db_pool_max_lifetime_closed_total", "Connections closed because their lifetime expired.", labels, nil),
|
||||
}}
|
||||
}
|
||||
|
||||
func (c *dbPoolCollector) Describe(ch chan<- *prometheus.Desc) {
|
||||
for _, desc := range c.desc {
|
||||
ch <- desc
|
||||
}
|
||||
}
|
||||
|
||||
func (c *dbPoolCollector) Collect(ch chan<- prometheus.Metric) {
|
||||
pod := metricPodName()
|
||||
c.pools.Range(func(key, value any) bool {
|
||||
pool, ok := key.(string)
|
||||
provider, providerOK := value.(dbStatsProvider)
|
||||
if !ok || !providerOK {
|
||||
return true
|
||||
}
|
||||
stats := provider.Stats()
|
||||
labels := []string{pod, pool}
|
||||
ch <- prometheus.MustNewConstMetric(c.desc["max_open"], prometheus.GaugeValue, float64(stats.MaxOpenConnections), labels...)
|
||||
ch <- prometheus.MustNewConstMetric(c.desc["open"], prometheus.GaugeValue, float64(stats.OpenConnections), labels...)
|
||||
ch <- prometheus.MustNewConstMetric(c.desc["in_use"], prometheus.GaugeValue, float64(stats.InUse), labels...)
|
||||
ch <- prometheus.MustNewConstMetric(c.desc["idle"], prometheus.GaugeValue, float64(stats.Idle), labels...)
|
||||
ch <- prometheus.MustNewConstMetric(c.desc["wait"], prometheus.CounterValue, float64(stats.WaitCount), labels...)
|
||||
ch <- prometheus.MustNewConstMetric(c.desc["wait_duration"], prometheus.CounterValue, stats.WaitDuration.Seconds(), labels...)
|
||||
ch <- prometheus.MustNewConstMetric(c.desc["max_idle_closed"], prometheus.CounterValue, float64(stats.MaxIdleClosed), labels...)
|
||||
ch <- prometheus.MustNewConstMetric(c.desc["max_lifetime_closed"], prometheus.CounterValue, float64(stats.MaxLifetimeClosed), labels...)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
var dbPools = newDBPoolCollector()
|
||||
|
||||
// RegisterDBPool exposes database/sql pool pressure for one logical database.
|
||||
// Registering the same name again replaces the provider, which keeps tests and
|
||||
// adapter reopen operations safe.
|
||||
func RegisterDBPool(name string, provider dbStatsProvider) {
|
||||
if provider == nil {
|
||||
return
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = "default"
|
||||
}
|
||||
dbPools.pools.Store(name, provider)
|
||||
}
|
||||
|
||||
func init() {
|
||||
Registry.MustRegister(dbPools)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fixedDBStats struct {
|
||||
stats sql.DBStats
|
||||
}
|
||||
|
||||
func (s fixedDBStats) Stats() sql.DBStats { return s.stats }
|
||||
|
||||
func TestDBPoolCollector(t *testing.T) {
|
||||
RegisterDBPool("test", fixedDBStats{stats: sql.DBStats{
|
||||
MaxOpenConnections: 32,
|
||||
OpenConnections: 12,
|
||||
InUse: 7,
|
||||
Idle: 5,
|
||||
WaitCount: 9,
|
||||
WaitDuration: 1500 * time.Millisecond,
|
||||
}})
|
||||
|
||||
families, err := Registry.Gather()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]bool{
|
||||
"im_db_pool_max_open_connections": false,
|
||||
"im_db_pool_open_connections": false,
|
||||
"im_db_pool_in_use_connections": false,
|
||||
"im_db_pool_idle_connections": false,
|
||||
"im_db_pool_wait_total": false,
|
||||
"im_db_pool_wait_duration_seconds_total": false,
|
||||
}
|
||||
for _, family := range families {
|
||||
if _, ok := want[family.GetName()]; ok {
|
||||
want[family.GetName()] = true
|
||||
}
|
||||
}
|
||||
for name, found := range want {
|
||||
if !found {
|
||||
t.Errorf("metric %s was not gathered", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type testGroupMessageRuntime struct {
|
||||
shortStates map[string]bool
|
||||
shortValues map[string]string
|
||||
objects map[string][]byte
|
||||
sets map[string]map[string]bool
|
||||
deletedPrefixes []string
|
||||
deletedObjects []string
|
||||
}
|
||||
@@ -28,6 +29,7 @@ func newTestGroupMessageRuntime() *testGroupMessageRuntime {
|
||||
shortStates: make(map[string]bool),
|
||||
shortValues: make(map[string]string),
|
||||
objects: make(map[string][]byte),
|
||||
sets: make(map[string]map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,16 +188,24 @@ func (r *testGroupMessageRuntime) DeleteCachedObjectsByPrefix(tenantID types.Ten
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *testGroupMessageRuntime) AddCachedSetMember(types.TenantID, string, string, string) error {
|
||||
func (r *testGroupMessageRuntime) AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
|
||||
key := strconv.FormatInt(int64(tenantID), 10) + ":" + namespace + ":" + id
|
||||
if r.sets[key] == nil {
|
||||
r.sets[key] = make(map[string]bool)
|
||||
}
|
||||
r.sets[key][member] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *testGroupMessageRuntime) RemoveCachedSetMember(types.TenantID, string, string, string) error {
|
||||
func (r *testGroupMessageRuntime) RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
|
||||
key := strconv.FormatInt(int64(tenantID), 10) + ":" + namespace + ":" + id
|
||||
delete(r.sets[key], member)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *testGroupMessageRuntime) CachedSetContains(types.TenantID, string, string, string) (bool, error) {
|
||||
return false, nil
|
||||
func (r *testGroupMessageRuntime) CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error) {
|
||||
key := strconv.FormatInt(int64(tenantID), 10) + ":" + namespace + ":" + id
|
||||
return r.sets[key][member], nil
|
||||
}
|
||||
|
||||
func (r *testGroupMessageRuntime) AllowRate(types.TenantID, string, int, time.Duration) (bool, int, time.Duration, error) {
|
||||
@@ -355,6 +365,29 @@ func TestMessageRuntimeSeqAndClientMidIdempotency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanSendP2PUsesWarmRelationshipCache(t *testing.T) {
|
||||
oldRuntime := groupMsgRuntime
|
||||
runtime := newTestGroupMessageRuntime()
|
||||
groupMsgRuntime = runtime
|
||||
t.Cleanup(func() { groupMsgRuntime = oldRuntime })
|
||||
|
||||
tenantID := types.TenantID(7)
|
||||
userID := types.Uid(42)
|
||||
peerID := types.Uid(43)
|
||||
cacheFriendPair(tenantID, userID, peerID)
|
||||
|
||||
allowed, relationship, err := (relationsMapper{}).CanSendP2P(tenantID, userID, peerID)
|
||||
if err != nil || !allowed || relationship != types.RelationshipFriend {
|
||||
t.Fatalf("warm friend cache was not used: allowed=%v relationship=%q err=%v", allowed, relationship, err)
|
||||
}
|
||||
|
||||
cacheBlockOneWay(tenantID, peerID, userID)
|
||||
allowed, relationship, err = (relationsMapper{}).CanSendP2P(tenantID, userID, peerID)
|
||||
if err != nil || allowed || relationship != types.RelationshipBlockedByPeer {
|
||||
t.Fatalf("reverse block cache was not enforced: allowed=%v relationship=%q err=%v", allowed, relationship, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBumpMessageSeqFloorNeverMovesBackward(t *testing.T) {
|
||||
oldRuntime := groupMsgRuntime
|
||||
runtime := newTestGroupMessageRuntime()
|
||||
|
||||
@@ -3147,6 +3147,29 @@ func (relationsMapper) GetRelationshipContext(tenantID types.TenantID, userID, p
|
||||
}
|
||||
|
||||
func (relationsMapper) CanSendP2P(tenantID types.TenantID, userID, peerID types.Uid) (bool, string, error) {
|
||||
if userID.IsZero() || peerID.IsZero() || userID == peerID {
|
||||
return false, "", types.ErrMalformed
|
||||
}
|
||||
// A warm relationship cache is sufficient for the publish hot path. Block
|
||||
// entries are checked first in both directions; friendship and block writes
|
||||
// keep these sets coherent. Redis errors and cache misses fall back to the
|
||||
// authoritative database path below.
|
||||
blockCacheReady := true
|
||||
if blocked, err := cachedBlockContains(tenantID, userID, peerID); err != nil {
|
||||
blockCacheReady = false
|
||||
} else if blocked {
|
||||
return false, types.RelationshipBlockedByMe, nil
|
||||
}
|
||||
if blocked, err := cachedBlockContains(tenantID, peerID, userID); err != nil {
|
||||
blockCacheReady = false
|
||||
} else if blocked {
|
||||
return false, types.RelationshipBlockedByPeer, nil
|
||||
}
|
||||
if blockCacheReady {
|
||||
if friend, err := cachedFriendContains(tenantID, userID, peerID); err == nil && friend {
|
||||
return true, types.RelationshipFriend, nil
|
||||
}
|
||||
}
|
||||
ctx, err := (relationsMapper{}).GetRelationshipContext(tenantID, userID, peerID, "")
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
@@ -3168,6 +3191,9 @@ func (relationsMapper) BlockUser(tenantID types.TenantID, blockerID, blockedID t
|
||||
block, err := tenantAdp.TenantUserBlockUpsert(tenantID, blockerID, blockedID, now)
|
||||
if err == nil {
|
||||
cacheBlockOneWay(tenantID, blockerID, blockedID)
|
||||
// A positive friend cache must never outlive a new block. Removing both
|
||||
// directions also makes a later block-cache eviction fall back to MySQL.
|
||||
uncacheFriendPair(tenantID, blockerID, blockedID)
|
||||
}
|
||||
return block, err
|
||||
}
|
||||
|
||||
+4
-2
@@ -84,8 +84,10 @@
|
||||
"dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(mysql.118im.com:30306)/meeting_im?parseTime=true&collation=utf8mb4_0900_ai_ci",
|
||||
"tenant_registry_db": "hotgo",
|
||||
"tenant_db_routing": true,
|
||||
"max_open_conns": 64,
|
||||
"max_idle_conns": 64,
|
||||
"max_open_conns": 24,
|
||||
"max_idle_conns": 8,
|
||||
"registry_max_open_conns": 4,
|
||||
"registry_max_idle_conns": 2,
|
||||
"conn_max_lifetime": 60,
|
||||
"sql_timeout": 10
|
||||
}
|
||||
|
||||
@@ -272,6 +272,7 @@ type testSecurityStore struct {
|
||||
consumedTarget string
|
||||
independentPassword *types.IndependentPassword
|
||||
youthMode *types.YouthModeSettings
|
||||
youthModeReads int
|
||||
attempts int
|
||||
lockedUntil *time.Time
|
||||
notifications []types.SystemNotification
|
||||
@@ -379,6 +380,7 @@ func (s *testSecurityStore) UpdateIndependentPasswordAttempts(_ types.TenantID,
|
||||
}
|
||||
|
||||
func (s *testSecurityStore) GetYouthMode(_ types.TenantID, uid types.Uid) (*types.YouthModeSettings, error) {
|
||||
s.youthModeReads++
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user