Compare commits

..
25 Commits
Author SHA1 Message Date
Austin 7c550aaf9d feat: 新增关系类型及按话题查询通话记录接口
- 新增 RelationshipSelf 关系类型
- 新增 serveIMCallsByTopic 接口处理按话题查询通话记录
- 实现群成员资料接口返回本人时的 self 关系处理
- 更新接口文档说明本人查询时返回 relationship: self
2026-09-29 10:46:32 +08:00
Austin 0718da784f docs: 移除 IM API 文档中接口的“待实现”标记 2026-09-29 10:26:27 +08:00
Austin 2f8ded104d docs: 添加单聊已读/已收回执协议约定文档 2026-09-29 09:53:24 +08:00
Austin 1ff0894205 fix(db): 移除消息查询中已废弃的 deletedat 条件
docs: 更新文件上传和消息补偿相关接口文档
2026-09-28 17:45:04 +08:00
richjohnrambo a974572f72 test: stabilize distributed load verification 2026-09-28 09:50:33 +08:00
richjohnrambo d3c679878d test: throttle history verification 2026-09-28 08:17:14 +08:00
richjohnrambo cb2210aa3e test: harden group load verification 2026-09-28 00:58:24 +08:00
richjohnrambo 622fc50418 fix: keep load agents connected before start 2026-09-28 00:16:51 +08:00
richjohnrambo 6b8ed4a2cc perf: batch group unread fanout 2026-09-27 23:10:28 +08:00
richjohnrambo fc875cc84e test: report ambiguous group publish outcomes 2026-09-27 22:59:17 +08:00
richjohnrambo e90e92a59e docs: standardize load test runtime package names 2026-09-27 22:31:53 +08:00
richjohnrambo 72e6c7b2c7 docs: record p2p result and next load stage 2026-09-27 22:17:57 +08:00
richjohnrambo ea8732e2b1 fix: keep load test websocket sessions alive 2026-09-27 21:41:50 +08:00
richjohnrambo bde02e8eac perf: expose and reduce message publish latency 2026-09-27 21:08:35 +08:00
richjohnrambo 8e778726e0 fix: cache tenant routing on message hot path 2026-09-27 20:10:18 +08:00
richjohnrambo 36b52e120c docs: record p2p 500 pool retest 2026-09-27 20:01:32 +08:00
richjohnrambo 3bb6774786 fix: protect publish path from mysql exhaustion 2026-09-27 18:35:54 +08:00
richjohnrambo 36eb76a5e3 docs: record p2p 500 load test bottleneck 2026-09-27 17:45:39 +08:00
richjohnrambo 0cf88e6e97 docs: add staged dual-machine pressure test runbook 2026-09-27 17:10:01 +08:00
richjohnrambo 3fb95fd5d9 test: report exact ACK latency percentiles 2026-09-27 17:05:06 +08:00
richjohnrambo d96f661f30 fix: reduce persistence lag and retry audit deadlocks 2026-09-27 16:33:05 +08:00
richjohnrambo 704c65a7a4 docs: record post-GitOps p2p pressure results 2026-09-27 16:14:22 +08:00
richjohnrambo 188164ce82 test: capture ordering regressions and enforce deploy resources 2026-09-27 14:05:21 +08:00
richjohnrambo d0e1e98f5f repair stale loadtest friendship setup 2026-09-27 13:40:58 +08:00
richjohnrambo ecfe13ff0e use unique p2p preflight idempotency keys 2026-09-27 13:36:12 +08:00
71 changed files with 3294 additions and 356 deletions
+31 -6
View File
@@ -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 && \
@@ -62,11 +66,32 @@ jobs:
kubectl rollout restart statefulset/im-server -n im-server-system
kubectl rollout status statefulset/im-server -n im-server-system --timeout=300s
resources="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources}')"
echo "Waiting for ArgoCD reconciliation before validating effective settings"
sleep 60
request_cpu="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.requests.cpu}')"
request_memory="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.requests.memory}')"
limit_cpu="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.limits.cpu}')"
limit_memory="$(kubectl get statefulset/im-server -n im-server-system -o \
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 resources=${resources} liveness=${liveness}"
if [ "${liveness}" != "/livez" ] || ! echo "${resources}" | grep -q '"cpu":"2"'; then
echo "::warning::StatefulSet resources/probes were reconciled away; update the ArgoCD source kustomization"
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" ] || [ "${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
+38
View File
@@ -0,0 +1,38 @@
# im-server-test ArgoCD patch
The live StatefulSet is owned by the ArgoCD Application `im-server-test`.
Copy `statefulset-performance-patch.yaml` into that application's Git source
and reference it from the test overlay:
```yaml
patches:
- path: statefulset-performance-patch.yaml
```
Also include `backend-debug-metrics-rbac.yaml` as a resource in the test
overlay. It grants the existing `argocd/backend-debug` service account
namespaced read access to Pod metrics so the load-test collector can run
`kubectl top pods -n im-server-system`:
```yaml
resources:
- backend-debug-metrics-rbac.yaml
```
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}'
kubectl auth can-i list pods.metrics.k8s.io \
--as=system:serviceaccount:argocd:backend-debug \
-n im-server-system
```
Expected values are requests `1 CPU / 512Mi`, limits `2 CPU / 1Gi`, and
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.
@@ -0,0 +1,27 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: backend-debug-metrics-reader
namespace: im-server-system
rules:
- apiGroups:
- metrics.k8s.io
resources:
- pods
verbs:
- get
- list
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: backend-debug-metrics-reader
namespace: im-server-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: backend-debug-metrics-reader
subjects:
- kind: ServiceAccount
name: backend-debug
namespace: argocd
@@ -0,0 +1,50 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: im-server
namespace: im-server-system
spec:
template:
spec:
containers:
- name: im-server
env:
- name: CLUSTER_PROXY_TO_MASTER_BUFFER
value: "4096"
- name: CLUSTER_PROXY_TO_MASTER_WORKERS
value: "16"
- name: CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS
value: "300"
- name: CLUSTER_ROUTE_RETRIES
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"
memory: 512Mi
limits:
cpu: "2"
memory: 1Gi
readinessProbe:
httpGet:
path: /healthz
port: 6060
timeoutSeconds: 3
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /livez
port: 6060
timeoutSeconds: 3
periodSeconds: 20
failureThreshold: 3
+4 -2
View File
@@ -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",
+4
View File
@@ -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
@@ -6,6 +6,8 @@
> 双机实际操作以 [`loadtest/im-pressure/README.md`](../loadtest/im-pressure/README.md) 为准。当前开发机固定为机器 A,负责数据准备、协调器、agent A、集中采集和最终报告;另一台机器固定为机器 B,只运行 agent B 并回传结果。
> 当前测试环境实际企业码为 `allrich`,沿用本方案最初为 006 商户定义的账号规模、场景和统计口径。已完成的 200 对复测、故障根因和修复证据见 [`docs/test/IM-单聊压测Pod日志根因分析与改进方案-20260927.md`](./test/IM-单聊压测Pod日志根因分析与改进方案-20260927.md)。
本次测试使用 006 商户的 2000 个账号,在两台压测机上各运行 1000 个在线 WebSocket 用户,验证多 Pod 场景下:
1. 1000 对 P2P 会话同时收发时,消息是否被接受、实时投递并最终持久化。
@@ -128,7 +130,7 @@ Idempotency-Key: <unique key for write requests>
1. `max_subscriber_count >= 2500`。仓库配置默认是 128,必须在所有 Pod 上统一调高并滚动重启。
2. 006 商户 `max_group_members` 为 0(不限)或不小于 2000;目标群 `max_member_count` 同样满足要求。
3. IM Server 所有 Pod 版本和配置一致,原生 Cluster 成员数与 Running Pod 数一致。
4. Kafka 的 P2P、群消息持久化 Topic 可写,消费组无持续积压,死信在基线时为 0。
4. Kafka 的 P2P、群消息持久化 Topic 可写,消费组无持续积压,死信在基线时为 0;测试环境的 `im.message.persist` 和 `im.group.message.persist` 均为 3 分区、3 副本。
5. 两台压测机到目标入口网络稳定,文件描述符上限、临时端口和 NAT 容量足够。
6. 两台压测机使用 chrony/NTP,同一时刻的时钟偏差绝对值小于 5ms,否则跨机器单向延迟无效。
7. 每台压测机至少预留 2 CPU、4GB 内存和稳定的 100Mbps 网络;测试进程自身 CPU 不应长期超过 70%。
@@ -357,8 +359,8 @@ e2e_realtime_latency = t_data - sent_at
| 群实时投递丢失率 | <= 0.001%,发布版本验收目标为 0 |
| 重复投递率 | 0 |
| Topic `seq` 回退 | 0 |
| ACK p95 / p99 | < 100ms / < 300ms |
| 实时投递 p95 / p99 | < 200ms / < 500ms |
| ACK p95 / p99 | <= 500ms / <= 1s;必须用原始 ACK 时间戳计算,不能用直方图桶上界代替 |
| 实时投递 p95 / p99 | <= 1s / <= 2s;跨机器时要求时钟偏差达标 |
| `deliver/hub_full`、WS 写队列满 | 0 |
| Kafka producer/commit 错误 | 0 |
| Kafka 消费重试、单条降级、死信新增 | 0 |
@@ -787,8 +789,33 @@ results/<run_id>/report.md
2. 完成 1000 对好友关系与 2000 人群,运行 `verify` 直到全部通过。
3. 先用 20 对 P2P、100 人群做冒烟测试,验证统计口径。
4. 执行 P0 热身。
5. 执行 S1,等待服务端和 Kafka 恢复基线至少 5 分钟。
6. 执行 S2,等待恢复基线至少 10 分钟。
7. 执行 S3。
5. P2P 按 200 对、500 对、1000 对逐级执行;每一级通过后才能进入下一级。
6. 执行 S2 群聊,等待恢复基线至少 10 分钟。
7. 执行 S3 混合场景。
8. 合并结果,按实时链路与持久化链路分别给出结论。
9. 全部稳定后再单独进行 Pod 重启、断网或 Kafka/Redis 故障注入,不与本轮无故障基线混跑。
### 14.1 P2P 容量阶梯
三个档位复用同一批账号和好友关系,只裁剪 runtime manifest,不重新注册或重复加好友。每档至少执行两轮;发布验收档建议执行三轮。任何一轮出现消息丢失、重复、乱序、非预期拒绝、数据库不一致或 Pod 重启,都必须停止升级并先定位。
| 档位 | A/B 用户 | WS 总数 | 每用户消息 | Publish 总数 | 目标 |
|---|---:|---:|---:|---:|---|
| P2P-200 | 各 200 | 400 | 10 | 4000 | 验证正确性、指标和基础延迟 |
| P2P-500 | 各 500 | 1000 | 10 | 10000 | 验证 Kafka 三分区、MySQL 和 Cluster 中档容量 |
| P2P-1000 | 各 1000 | 2000 | 10 | 20000 | 单聊发布验收 |
每档的执行流程固定为:生成唯一 `run_id`;开始前采集至少 2 分钟基线;启动 coordinator;A/B agent 全部 READY;统一时间发压;P2P 发送结束后继续监听至少 30 秒;等待 Kafka lag 归零;执行历史回查与 MySQL `client_mid` 精确核验;发送结束后继续采集至少 5 分钟;最后合并报告。
每档升级门槛:Ready、Publish ACK、实时到达、历史查询和 MySQL 精确匹配全部达到期望数;丢失、重复、乱序、断连、403/502/503、Cluster queue overflow、Kafka retry/dead letter、MySQL deadlock、Pod 重启均为 0;Kafka lag 在发送结束后 60 秒内归零;Pod 无持续 CPU throttling。ACK P95 超标时必须按压测机分别报告,并先排除 DNS、TCP、TLS、WebSocket RTT 和时钟偏差。
### 14.2 P2P 通过后的业务场景
1. **GROUP-2000**:2000 条 WS 全部加入同一群,A/B 各 500 个发送者;每人发送 2 条,两轮合计 2000 条。实时投递期望量按每条消息的实际 Ready 接收者计算,并保留 60 秒 drain。
2. **MIXED**:300 对私聊用户双方各发 5 条,共 3000 条私聊 Publish;同时 A/B 各 250 个群发送者每人发 2 条,共 1000 条群 Publish。
3. **稳定性复跑**:GROUP 和 MIXED 至少各两轮,确认无冷启动偶然性。
4. **故障注入**:分别执行单 Pod 滚动重启、Kafka 短暂不可用与恢复、Redis 短暂不可用与恢复;故障轮只验证恢复能力,不与无故障性能基线混合统计。
### 14.3 当前下一步
当前已通过 P2P-200 的消息正确性、Kafka 60 秒排空、MySQL 精确落库、零乱序、零审计死锁和零 CPU throttling。下一步执行 **P2P-500**:A/B 各取账号索引 `1..500`,共 1000 条 WS、10000 条 Publish。若连续两轮满足上述升级门槛,再进入 P2P-1000。
+21 -8
View File
@@ -398,6 +398,14 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
`kp/kpa/kpv` 只表示输入态、录音态、视频输入态等短暂 UI 状态,不能作为长连接心跳。长连接保活必须依赖 0.5.1.1 中的 WebSocket Ping/Pong。
**单聊已读/已收回执协议约定(服务端已验证,2026-09-29):**
1. **在线下行形态**:`info.topic` 为话题名(如 `p2p...`),`info.from` 为报读者,`info.what` 为 `recv`/`read`,`info.seq` 为水位。服务端跳过上报会话自身,推送给对端及本人其他在线设备(多 Pod 部署经 Kafka 同步,各 Pod 均会下发)。
2. **离线补发形态**:接收方离线时收到 `{"info":{"topic":"me","src":"p2p...","from":"usr...","what":"recv","seq":12}}`,即 `topic` 固定为 `me`、`src` 才是话题名。客户端必须同时兼容上述两种形态。
3. **序号单调递增**:服务端丢弃 `seq` 不大于当前水位的回执(`read`、`recv` 各自独立判断),`read` 回执会同步抬升 `recv`,恒有 `read ≥ recv`。客户端渲染一律取服务端最大游标,禁止用本地计数覆盖服务端值。
4. **断线重连恢复**:`sub`/`get desc` 响应的 `desc` 中包含 `seq`、`read`、`recv`、`clear` 四个游标;`get {"what":"sub"}` 也返回各成员的 `read/recv`。重连后以这些游标恢复本地状态,不要依赖进程内存。
5. **换设备登录**:回执水位按「用户 × 话题」在服务端持久化,与设备无关;新设备订阅后 `desc` 同时返回自己与对端的最新 `read/recv`,可完整恢复对方"已读/已收"状态。发送者自身的 `read/recv` 由服务端自动推进到其最新发出的 `seq`。
##### 0.5.4.5 Topic 资料、个人群设置和标签
| 场景 | 客户端发送 | 服务端返回/下行示例 |
@@ -1140,7 +1148,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
- 选择媒体后本地生成 `client_mid` 和稳定占位;调用文件上传,得到 `file_id` 后再 `pub x-im-type=image|video|voice`。
- `content` 至少含 `file_id,name,size,mime,width,height,duration_ms,thumbnail_file_id` 中适用字段;上传进度是客户端任务状态,不写成聊天消息。
- 服务端发布成功后关联文件与 Topic/消息;文件下载必须校验消息可见性。上传失败只重试上传,成功但 `pub` 失败时复用已上传 `file_id` 和 `client_mid`。
- 服务端发布成功后关联文件与 Topic/消息;文件下载必须校验消息可见性。
- 上传失败(网络中断、超时、5xx)按**整文件重传**处理:重新调用 `POST /v0/file/u/` 上传完整文件,不做分片/断点续传;每次重传得到新 `file_id`,旧 `file_id` 直接放弃、由服务端 TTL 回收,客户端不得再引用。
- 上传成功但 `pub` 失败时不重新上传,复用已上传 `file_id` 和原 `client_mid` 重发消息,服务端按 `client_mid` 幂等去重。
### 3.7 个人对话—拉黑状态/已读状态.png
@@ -1294,7 +1304,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:客户端任务管理新增;服务端上传需改造。**
- 每个待发送项保存 `client_mid,local_uri,type,upload_id,progress,state`;同一文件可分片/断点续传时使用稳定 `upload_id`。
- 每个待发送项保存 `client_mid,local_uri,type,upload_id,progress,state`;`upload_id` 仅在单次上传任务内有效(进度上报与本地去重用)。
- 重试即新建上传任务:`progress` 归零、生成新 `upload_id`,**整文件重传** `POST /v0/file/u/`,不做分片/断点续传;重传成功后消息体 `file_id` 等字段全部取最新一次上传响应。
- 自动重试采用指数退避且不超过 3 次,之后转人工点按重试;App 冷启动扫描本地 `failed` 任务队列重新上传。
- 上传完成后再发布消息;多任务可并发但必须各自保持原排序号。离开页面不取消,退出账号必须暂停并隔离任务。
- 服务端返回 `FILE_TOO_LARGE`、`FILE_TYPE_NOT_ALLOWED`、`STORAGE_QUOTA_EXCEEDED` 时不可自动无限重试。
@@ -1410,10 +1422,10 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/conversations/{topic}/context` | Path `p2p...` 或 `grp...` | `{"topic":"p2p...","type":"p2p","relationship":"friend","can_send":true,"capabilities":{"text":true,"image":true,"file":true,"voice":true,"can_call":true},"peer":{"user_id":"usr...","display_name":"张三","avatar_file_id":""}}` |
| `POST /v1/im/conversations/p2p` 待实现 | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` |
| `GET /v1/im/conversations/{topic}/settings` 待实现 | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` |
| `PATCH /v1/im/conversations/{topic}/settings` 待实现 | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` |
| `GET /v1/im/conversations/{topic}/messages` 待实现 | Query `?since=12&limit=20` | `{"topic":"p2p...","items":[{"seq":13,"from":"usr...","ts":"2026-08-20T09:00:00Z","head":{"x-im-type":"text"},"content":{"text":"你好"}}],"next_cursor":""}` |
| `POST /v1/im/conversations/p2p` | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` |
| `GET /v1/im/conversations/{topic}/settings` | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` |
| `PATCH /v1/im/conversations/{topic}/settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` |
| `GET /v1/im/conversations/{topic}/messages` | Query `?since=12&limit=20` | `{"topic":"p2p...","items":[{"seq":13,"from":"usr...","ts":"2026-08-20T09:00:00Z","head":{"x-im-type":"text"},"content":{"text":"你好"}}],"next_cursor":""}` |
| `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/topics/{topic}/messages/{seq}/recall` | 单条撤回;`topic` 可传 `p2p...` 或 `grp...`;Body `{}`,可选 `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/topics/{topic}/messages/recall` | 多选批量撤回;`{"items":[{"seq":12,"version":1},{"seq":13}],"reason":"mistake"}`;`items` 1~50 条,`version` 可选 | `{"topic":"grp...","items":[{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"},{"seq":13,"status":"failed","http":409,"code":30006,"reason":"VERSION_CONFLICT","message":"message version conflict"}]}` |
@@ -1667,6 +1679,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `mutation_version` 是会话级水位,不是单条消息的 `version`。
- 本地只做 `服务端 mutation_version > 本地 mutation_version` 比较,不要假设连续递增。
- 补偿接口返回的是最新 `limit` 条(默认 50、上限 100)变更的**状态快照**,非严格增量;变更条数超过 `limit` 时更早的变更不再返回。撤回窗口 120 秒、编辑窗口 300 秒,超窗变更无需客户端处理。
- 拉到 `message.recalled` 后,将本地 `topic + seq` 标记为撤回,并只在 `version` 更高时覆盖。
- 拉到 `message.edited` 后,按 `seq + version` 拉取或覆盖消息内容。
- 实时事件和补偿接口可能重复到达,客户端必须按 `topic + seq + version` 幂等处理。
@@ -2162,7 +2175,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"...","file_name":"方案.pdf","mime_type":"application/pdf","file_size":1024}` 建立群文件记录。
- 服务端校验 `member_upload_files`、文件是否已上传完成、`file_name` 长度不超过 255、`mime_type` 长度不超过 128、`file_size` 非负且与上传记录一致、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。
- 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。
- 上传失败按整文件重传重新上传并取得新 `file_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。
### 6.29 接口示例
@@ -2191,7 +2204,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","group_nickname":"小张","avatar_file_id":"","role":"member","member_status":"active","version":1,"joined_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-20T09:00:00Z","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","sync_mode":"full","permissions":{"can_mention_all":true}}` |
| `GET /v1/im/groups/{topic}/members/{user_id}` | Path topic/user | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","member_status":"active","version":1,"joined_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-20T09:00:00Z","sync_mode":"single","acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}` |
| `DELETE /v1/im/groups/{topic}/members/{user_id}` | Path topic/user;管理员可移除普通成员,群主可移除管理员和普通成员;不能移除群主或自己 | `{"topic":"grp...","user_id":"usr...","status":"left"}` |
| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` |
| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...`;`user_id` 可传本人,返回 `relationship:"self"`、`capabilities.can_send=true` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` |
| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group":{"topic":"grp...","name":"项目群","member_count":2},"group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` |
| `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` |
| `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` |
+1 -1
View File
@@ -123,7 +123,7 @@ HEALTHCHECK --interval=1m --timeout=3s --start-period=20s \
| 探针 | 路径 | 初始延迟 | 周期 | 超时 | 失败次数 |
|---|---|---:|---:|---:|---:|
| im-server readinessProbe | `/healthz` | 15s | 10s | 3s | 3 |
| im-server livenessProbe | `/healthz` | 30s | 30s | 3s | 3 |
| im-server livenessProbe | `/livez` | 15s | 20s | 3s | 3 |
| im-manager readinessProbe | `/healthz` | 15s | 10s | 3s | 3 |
| im-manager livenessProbe | `/healthz` | 30s | 30s | 3s | 3 |
@@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS im_user_login_logs (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
INDEX idx_im_user_login_tenant_time (tenant_id, login_at DESC, id DESC),
INDEX idx_im_user_login_session_close (tenant_id, session_id, logout_at),
INDEX idx_im_user_login_user_time (user_id, login_at DESC, id DESC),
INDEX idx_im_user_login_ip_time (ip, login_at DESC, id DESC),
INDEX idx_im_user_login_device_time (device_id(64), login_at DESC),
@@ -16,37 +16,63 @@
| 机器 A | 当前开发机,`192.168.1.135` |
| 机器 B | 另一台压测机,运行 `-machine b` |
`config.json`、`.env.test`、`manifest.json` 均被 Git 忽略。manifest 含 2000 个账号 Token,只能通过受控内网传输。
`config.json`、`.env.test`、`manifest.json` 均被 Git 忽略。manifest 含账号 Token,只能通过受控内网传输。
## 2. 数据准备结果
2026-09-27 已完成并通过硬校验:
2026-09-27 首次准备曾按 2000 账号目标执行。当前分阶段压测使用的实际运行文件为:
- 账号:2000,机器 A/B 各 1000。
- 用户名:`lta0001-lta1000`、`ltb0001-ltb1000`。
- 测试手机号:A 使用配置前缀加 `0001-1000`,B 使用配置前缀加 `5001-6000`。
- 好友关系:1000 对,序号相同的 A/B 用户互为好友。
- P2P Topic:1000 个,全部写入 manifest。
- 测试群:1 个,成员数 2000,Topic 仅在 manifest 中保存,文档只记录哈希 `98372d54fe65`。
- `users_per_machine=500`。
- 账号:1000,机器 A/B 各 500。
- 好友关系与 P2P Topic:500 对。
- 测试群:1 个,当前 manifest 的 `group_topic=grp3Ita5tyBIMs`。
正式的 2000 人群测试前,必须将 `users_per_machine` 恢复为 1000,在机器 A 重新执行 `prepare` 和 `verify`,并将新 manifest 同步给机器 B。不能用当前 1000 人清单宣称已完成 2000 人群压测。
准备过程观察到注册和好友同意接口存在少量瞬时 500/404,100 人群邀请批次存在 502、超过 2 分钟及 `IDEMPOTENCY_IN_PROGRESS`。工具使用相同业务幂等键和退避重试后全部恢复;这些属于准备链路现象,不计入正式消息到达率。
## 3. 当前运行状态
## 3. 最新有效轮次
| 项目 | 值 |
|---|---|
| Run ID | `p2p-20260927-01` |
| Run ID | `p2p500-r5-20260927-05` |
| 场景 | `p2p` |
| Coordinator | `http://192.168.1.135:8090` |
| 机器 A agent | `1000/1000 READY`,意外断连 0 |
| 机器 B agent | 等待启动 |
| 远端 dev | `6d17154` 或更新版本 |
| Git 提交 | `ea8732e` |
| 连接 | A/B 各 500,合计 1000/1000 READY |
| 发送 | 10000/10000 获得 ACK |
| 实时到达 | 10000/10000,到达率 100% |
| 历史落库 | 10000/10000,落库率 100% |
| 异常 | 丢失 0、重复 0、乱序 0、断连 0、发送错误 0 |
| ACK 延迟 | P50 约 650ms,P95 1313ms,最大 2075ms |
| 同机 echo 投递 P95 | 2s 直方图桶 |
| 报告门禁 | 正确性通过;延迟门禁未通过 |
机器 A 的 coordinator 和 agent A 已运行。机器 B 启动并达到 1000 READY 后,coordinator 自动下发统一开始时间,无需人工再次触发。
本轮中 A/B 时钟未对齐,A 端记录到 4334 个跨机器负延迟样本,因此不使用跨机器单向延迟作为结论;延迟门禁使用发送机本机 ACK 和 echo 口径。
## 4. 机器 B 运行文件
### 3.1 服务端阶段耗时
机器 A 已生成:
| 阶段 | 平均耗时 | P95 所在桶 |
|---|---:|---:|
| `relationship_check` | 66.9ms | 250ms |
| `redis_idempotency` | 21.1ms | 100ms |
| `redis_sequence` | 62.7ms | 250ms |
| `kafka_produce` | 11.0ms | 25ms |
| `save` | 116.9ms | 500ms |
`meeting_im` 和 `hotgo` 连接池在该轮起压窗口的等待次数和等待时间增量均为 0,未出现连接池枯竭。
### 3.2 Kafka 和落库
- `im-server.message_persist` 的 consumer lag 峰值为 4663。
- lag 在发压结束后约 2 分钟清零。
- 测试窗口中批量持久化的单批平均耗时约 3.52s,P95 落在 10s 直方图桶。
- agent 在等待 Kafka 追平后回查历史,10000 条 ACK 消息全部找到,没有持久化丢失。
结论是当前系统能在突发后最终消化积压,但前台 ACK 和异步落库延迟仍高,这两项应单独作为后续优化指标。
## 4. 历史机器 B 运行文件
以下为首轮历史运行包,不能继续用于新轮次:
```text
/tmp/allrich-machine-b-runtime-p2p-20260927-01.zip
@@ -89,3 +115,36 @@ go build -o im-pressure .
## 5. 多 Pod 口径说明
当前 A/B 配置均使用 `wss://api.118im.com/v0/channels` 公共入口。该配置验证 LB 实际分配下的多 Pod 能力,但不能保证两台压测机固定落到不同 Pod。若验收要求强制指定不同 Pod,需要提供两个独立 WS 定向入口,分别配置到 `endpoints.a` 和 `endpoints.b`。
## 6. 后续测试顺序
### 6.1 P0:扩容到 2000 个用户
1. 将机器 A 的 `config.json` 修改为 `users_per_machine=1000`。
2. 机器 A 执行 `prepare` 和 `verify`,确认 2000 账号、1000 对好友、1000 个 P2P Topic 和 2000 人群都就绪。
3. 重新生成包含 `.env.test`、`config.json`、`manifest.json` 的 B 机运行包,B 机解压后执行 `verify`。
4. A/B 都执行 NTP 校时,要求偏差小于 50ms,否则继续忽略跨机器单向延迟。
### 6.2 P1:2000 人群聊
使用 `scenario=group`:
- A/B 各建立 1000 个 WS,合计 2000 个在线群成员。
- A/B 各取前 500 个发送者,每人 2 条,合计 2000 条群消息。
- A/B 交错每 100ms 发送 1 条,单轮发送窗口约 100s。
- 期望实时投递为 2000 条消息 x 2000 连接,即 400 万次有效到达。
- 检查群消息 ACK、每个 WS 的到达数、重复、乱序、断连、`im.group.message.persist` lag 和群消息月表落库。
群聊是下一个应执行的场景,因为它直接验证本次多 Pod 改造的 Redis fanout 和群消息 Kafka 持久化。
### 6.3 P2:混合场景
群聊正确性通过后使用 `scenario=mixed`:
- 300 对私聊用户双向各发 5 条,合计 3000 条私聊消息。
- A/B 各 250 个群发送者,每人 2 条,合计 1000 条群消息。
- 同时观察私聊与群聊的到达率、两个 Kafka 持久化 Topic 的 lag,以及 Redis、MySQL 和 3 个 Pod 的资源争用。
### 6.4 P3:单聊完整规模回归
最后在 2000 连接下重跑 `p2p`,即 1000 对用户双向各发 10 条,共 20000 条。延迟门禁仍为 ACK P95 <= 500ms、同机 echo P95 <= 1000ms;若只有正确性通过而延迟未通过,结论必须标记为“可靠性通过,性能未达标”。
@@ -0,0 +1,145 @@
# IM 2000 人群聊压测问题分析
日期:2026-09-27
Run ID:`group2k-r1-20260927-01`
场景:A/B 各 1000 个 WebSocket,各 500 个发送者发 2 条,计划发送 2000 条群消息。
## 1. 结论
本轮失败,且不能仅解读为 WebSocket 丢包。核心问题是 2000 人群每条消息触发 O(群成员数) 的额外用户 `me` Topic 路由,导致 Topic master、集群通道和 MySQL 连接池连锁积压。服务端处理吞吐下降后,发送端先经历 ACK 超时,随后 Topic 队列写满并返回 503。
同时,压测程序对 ACK 超时的口径不完整:超时后服务端仍可能接受、广播并落库,不能把 `ctrl_timeout` 直接当作明确拒绝。
## 2. 客户端结果
| 指标 | A | B | 合计 |
|---|---:|---:|---:|
| WS READY | 1000 | 1000 | 2000 |
| 发送尝试 | 1000 | 1000 | 2000 |
| 收到 2xx ACK | 16 | 19 | 35 |
| 503 | 224 | 272 | 496 |
| ACK 超时 | 732 | 709 | 1441 |
| socket 错误 | 28 | 0 | 28 |
| 断线 | 21 | 0 | 21 |
| ACK 台账内实时缺失 | 735 | 0 | 735 |
| ACK 台账内历史落库缺失 | 0 | 0 | 0 |
| 未在 ACK 台账中的实时投递 | 368270 | 377000 | 745270 |
35 条 ACK 消息理论上应产生 70000 次本轮投递,实际共记录 69265 次,其中 A 端少 735 次。但这不是服务端实际接受的全量;额外 745270 次投递来自“未及时收到 ACK,但服务端已处理”的消息。
## 3. 服务端证据
### 3.1 实际接受和落库
- `im_message_publish_stage_duration_seconds_count{kind="group",stage="save",result="ok"}` 增加 488。
- `im_kafka_message_persist_total{kind="group",result="batch_ok"}` 增加 488。
- 群消息 Kafka 批持久化平均约 46.6ms,488 条全部 batch success。
因此,服务端至少接受并落库 488 条,而不是客户端 ACK 台账中的 35 条。其中至少 453 条属于“已接受但 ACK 未在 20 秒内返回”。
### 3.2 队列溢出
| Pod | 日志 | 次数 |
|---|---|---:|
| `im-server-2` | `s.publish: sub.broadcast channel full` | 496 |
| `im-server-2` | `hub: topic's broadcast queue is full` | 1655 |
| `im-server-1` | `cluster: proxy channel full` | 47 |
群 Topic `grp3Ita5tyBIMs` 的 master 在 `im-server-2`。其 `clientMsg` 队列容量只有 192,proxy 响应队列容量为 128。队列满时会立即返回 503 或丢弃 proxy 响应。
### 3.3 每条群消息的路由放大
`Topic.saveAndBroadcastMessage` 在每条消息中同步执行:
1. `presSubsOffline("msg", ..., offlineOnly=true)` 遍历 `t.perUser` 的 2000 个成员。
2. 为每个成员构建一条用户 `me` Topic presence 消息。
3. `routeUserServerMessage` 同时走 Tinode 原生 Cluster 路由和 Redis user fanout。
4. 最后才向群 Topic 的会话广播 `{data}`。
488 条已接受群消息产生了约 98 万次额外用户路由:
- `realtime_fanout_events_total{stage="publish",target_type="user"}` 增加 979979。
- `realtime_fanout_events_total{stage="receive",target_type="user"}` 增加 894360。
- Redis 接收后真正本地投递只有 666 次,`no_local_session` 为 893694。
- Cluster route 约增加 109 万次。
这证明绝大多数用户 fanout 在当前全员已订阅群 Topic 的场景中完全无效,却阻塞了 Topic master 的串行处理协程。
### 3.4 MySQL 连接等待
3 个 Pod 的 `meeting_im` 连接池在测试窗口中合计:
- 等待次数增加 4011。
- 等待时间增加约 35399 秒。
- DB `exec ok` 增加 3453,平均耗时约 501ms。
连接池每 Pod 最大 24 个连接。这不是本轮第一个堵点,但会进一步拖慢群权限检查、未读数更新和其他业务 SQL。仅提高连接数不会消除 O(成员数) 的放大。
## 4. 压测程序口径问题
1. `ctrl_timeout` 和 socket 错误属于发布结果未知,不是明确拒绝。
2. recorder 能够统计 `unexpected_messages`,但 merge 报告没有汇总该字段,也没有将它加入门禁。
3. 历史回查只检查 ACK 台账,因此只检查了 35 条,无法单独证明其他超时消息的落库情况。
4. 群聊应增加“已 ACK”、“已明确拒绝”、“结果未知”、“未 ACK 但已观测广播”和“最终落库”五种状态。
## 5. 改进顺序
### P0
1. 停止在原生 Cluster 已开启时对同一条 user message 再做 Redis user fanout,避免双通道重复路由。
2. 重构群消息的 unread presence:已订阅该群 Topic 的用户不再生成额外 `me` 通知;未订阅但在线的成员使用节点级批量 fanout,不得在 Topic master 协程中逐人跨 Pod 路由。
3. 为 Topic `clientMsg`、proxy 响应队列和 session send queue 增加 length/capacity/drop Prometheus 指标。
4. 压测 merge 报告增加结果未知及 unacked-observed 口径,并把 `unexpected_messages` 纳入失败门禁。
### P1
1. 检查群消息未读数更新和 Push receipt 是否对 2000 人逐人访问 Redis/MySQL,改为批处理或延迟合并。
2. 为 `CanPost` 的全员禁言和单人禁言结果增加短 TTL 缓存,管理操作时精确失效。
3. P0 完成后先跑 100、500、1000、2000 人群阶梯,每档检查 ACK P95、Topic 队列水位、Cluster route 数、DB pool wait 和 Kafka lag,不直接重跑全量。
## 6. 本轮验收结论
- 连接建立:通过,2000/2000 READY。
- 发布成功率:失败。
- ACK 延迟:失败,已超过 20 秒超时窗口。
- 实时广播:失败,存在队列丢弃和断线。
- Kafka 持久化:已处理的 488 条全部成功,但该结论不代表计划的 2000 条均被接受。
- 总体:失败,必须完成 P0 后按阶梯重测。
## 7. P0 修复实现
本轮分析后的代码修复如下:
1. 群消息的 `{pres what=msg}` 不再由 Topic master 对每个成员逐条跨 Pod 路由。master 将收件人清单合并为一条 `group_unread/users` Redis Pub/Sub 消息;来源 Pod 本地投递一次,其他 Pod 各消费一次。
2. 每个 Pod 只扫描一次本地 WebSocket:已订阅该群 Topic 的连接依靠正常 `{data}` 广播,不重复发送 `me` presence;同一用户未订阅该群的其他设备仍收到 `me` presence,保留多开语义。
3. Redis fanout 未启用或批量发布失败时,自动回退到原来的逐用户 Topic 路由,避免静默漏掉未读通知。
4. 原生 Tinode Cluster 开启时,普通 user message 不再重复发布 Redis user fanout;session control 仍保留 Redis 跨 Pod 广播。
5. `routeSrv` 改为有界非阻塞写入,队列满时记录错误和指标,不再无限阻塞 Topic 处理链路。
6. 新增 `im_internal_queue_enqueue_total`、`im_internal_queue_depth`、`im_internal_queue_capacity`,覆盖 `session_topic_publish`、`hub_route_server`、`topic_client`、`topic_server`、`cluster_topic_proxy`。
修复后的复杂度从“每条消息约 2000 次 Cluster route + 2000 次 Redis publish”降为“一次 Redis publish + 每 Pod 一次本地 session 扫描”。2000 人都订阅群 Topic 时,批量 unread 在本地扫描后不会产生重复 WebSocket 帧。
## 8. 复测观测项
复测继续使用 `group2k-r1` 相同发送模型,并重点检查:
```promql
sum by (queue, result) (rate(im_internal_queue_enqueue_total[1m]))
histogram_quantile(
0.95,
sum by (le, queue) (rate(im_internal_queue_depth_bucket[1m]))
)
sum by (stage, kind, target_type, status) (
increase(realtime_fanout_events_total[5m])
)
```
验收要求:
- `session_topic_publish`、`topic_client`、`topic_server`、`cluster_topic_proxy` 的 `result="full"` 均为 0。
- `group_unread/users` 的 publish 数应接近服务端实际接受的群消息数,不再乘以群成员数。
- 群压测窗口内普通 `message/user` Redis publish 不再随群成员数增长。
- `im_cluster_route_total` 不应再出现每条群消息约 2000 次的线性放大。
- 2000 条计划消息需分别核对 ACK、未知结果、实时观测和最终落库,不能只按 ACK 台账判断落库率。
@@ -0,0 +1,134 @@
# IM 2000 人群聊 r4 复测分析
日期:2026-09-28
Run ID:`group2k-r4-20260928-01`
场景:机器 A/B 各 1000 个 WebSocket、各发送 1000 条,计划共发送 2000 条群消息。
## 1. 结论
本轮不能按合并报告中的 `14.39%` 实时丢失率直接判定服务端群消息丢失。两台压测机结果存在明显单边故障:
- 机器 B 的 1000 个连接全部稳定,1000 次发送全部收到 ACK,1497000 次 ACK 台账内投递全部到达,断线和实时缺失均为 0。
- 机器 A 出现 654 次断线、314 次 socket 错误和 189 次 ACK 超时。A 的 TCP 源地址错误中包含 `broken pipe`、`websocket closed`,是本轮 430832 次实时缺失的主要来源。
- 服务端实际接受并持久化 1601 条消息,其中客户端收到 ACK 1497 条,另外 104 条属于“客户端未收到 ACK,但服务端已接受”。B 恰好多收到 104000 次消息,即 104 条乘以 B 的 1000 个连接,账目完全吻合。
- ACK 台账内 1497 条消息历史回查全部找到,`persistence_lost=0`;Kafka 群消息消费 lag 在采样点均为 0。
- 上一轮的 O(群成员数) Redis fanout 放大已经消除:1601 条服务端消息只产生 1601 次 `group_unread/users` publish,每个远端 Pod 各 receive 1601 次,不再产生约 320 万次逐用户 Redis publish。
- 实时发送与 60 秒收尾阶段没有新增 `topic_server` 或 `cluster_topic_proxy` 满队列。满队列发生在约 5 分钟后的并发历史回查阶段,属于另一项需要优化的问题,不能作为本轮实时丢失的原因。
因此,本轮结论为:**批量群未读 fanout 修复通过;B 侧实时投递通过;数据库持久化通过;A 侧网络稳定性失败;历史回查并发会打满 Topic/Proxy 队列。整体尚不能完成 2000 人群聊验收。**
## 2. 客户端结果
| 指标 | A | B | 合计 |
|---|---:|---:|---:|
| READY 连接 | 1000 | 1000 | 2000 |
| 发送尝试 | 1000 | 1000 | 2000 |
| 收到 2xx ACK | 497 | 1000 | 1497 |
| 明确拒绝 | 0 | 0 | 0 |
| ACK 超时 | 189 | 0 | 189 |
| socket 结果未知 | 314 | 0 | 314 |
| 断线 | 654 | 0 | 654 |
| ACK 台账内应收 | 1497000 | 1497000 | 2994000 |
| ACK 台账内实收 | 1066168 | 1497000 | 2563168 |
| ACK 台账内实时缺失 | 430832 | 0 | 430832 |
| ACK 台账外额外投递 | 68668 | 104000 | 172668 |
| 重复 / 乱序 | 0 / 0 | 0 / 0 | 0 / 0 |
| 历史检查 / 找到 | 1497 / 1497 | 0 / 0 | 1497 / 1497 |
合并报告按 ACK 台账计算出的到达率为 `85.61%`,但这只是 A/B 网络条件混合后的观测值,不代表服务端总体投递率。B 单机的 ACK 台账内到达率是 `100%`,A 单机是 `71.22%`。
A 端记录了 160074 个跨机器负延迟样本,说明两机时钟没有达到单向延迟统计要求。本轮不使用跨机器 P95/P99 判断性能,只使用 ACK、到达计数、断线、历史回查和服务端阶段指标判断正确性。
## 3. 未收到 ACK 的 503 条不是服务端拒绝
本轮没有任何明确的 HTTP/Tinode `503` 响应。报告字段 `publish_rejected=503` 实际由以下结果未知项组成:
- `ctrl_timeout=189`
- `socket=314`
服务端持久化计数增加 1601,而 ACK 只有 1497,说明至少 104 条结果未知消息已被服务端成功接受。B 的 `unexpected_messages=104000` 与这 104 条消息对 1000 个 B 连接的广播数量完全一致。
后续报告应把字段继续显示为 `publish_unknown`,不能把它展示为 `rejected`,否则容易误判服务端主动拒绝。
## 4. 服务端与中间件
### 4.1 Kafka 与数据库落库
- `im_kafka_message_persist_total{kind="group",result="batch_ok"}` 增加 1601。
- `im.group.message.persist` consumer lag 在采样点均为 0。
- 1497 条 ACK 台账消息通过历史接口全部找到,持久化缺失为 0。
- 服务端群消息 `save` 总耗时增加约 26.27s,平均约 16.4ms/条。
- Kafka produce 总耗时增加约 19.15s,平均约 12.0ms/条。
这证明已接受消息能够及时进入 Kafka 并完成落库。104 条结果未知消息也计入 1601 条 batch success,但当前历史校验只以 ACK 台账为准,下一版应对“已观测但未 ACK”的消息也做 message ID 回查。
### 4.2 Redis fanout 修复验证
1601 条服务端接受消息对应:
- master Pod `group_unread/users publish ok` 增加 1601。
- 两个远端 Pod 的 `group_unread/users receive ok` 各增加 1601。
- 普通 `message/user` 在原生 Cluster 开启时继续使用 `skip_native_cluster`,没有恢复逐成员 Redis 发布。
这符合“一条群消息一次 Redis publish、每个 Pod 一次本地扫描”的设计,上一轮每条消息约 2000 次 Redis publish 的放大已经消失。
### 4.3 队列满发生在历史回查阶段
按时间序列拆分:
- `16:25:05Z` 开始发送。
- `16:28:36Z` 最后一台压测机结束发送,之后进入收尾和历史等待。
- 截至 `16:34:01Z`,`topic_server/full` 仍停留在测试前基线 1349,`cluster_topic_proxy/full` 仍为 0。
- `16:34:25Z` 起,历史回查并发开始使 `topic_server/full` 增加。
- 最终历史阶段新增 `topic_server/full=1355`、`cluster_topic_proxy/full=55`。
因此这些队列丢弃不是实时发送阶段 430832 次缺失的原因。历史回查一次并发拉取大量消息,经 Topic/Proxy 回送时会形成独立洪峰,应限制每机历史校验并发或分页节流,并单独验收历史吞吐。
## 5. A 机器故障判断
A 与 B 使用相同代码、相同公共入口和相同发送模型,但只有 A 发生 654 次断线。A 的错误包含:
- `websocket closed`
- `write: broken pipe`
- `ctrl timeout`
- 到多个公网入口 IP 的 TCP 读写超时
这种强烈单边性不符合服务端全局过载的表现。当前最可能的故障域依次是 A 本机网络/NAT/安全软件、A 到公网 LB 的链路、A 的文件描述符或本地调度压力。由于 Kubernetes Metrics API 被 RBAC 拒绝,本轮没有取得 Pod CPU/内存数据,不能用资源指标进一步排除 Pod 饱和。
## 6. 下一轮执行顺序
1. 先做 A/B 角色互换:B 仍发 1000 条,A 暂时只保持 1000 个连接不发送。若 A 仍大量断线,可确认与服务端发送负载无关。
2. 在 A 机同步采集 `ping`、TCP retransmit、连接跟踪、文件描述符、CPU、内存和进程调度数据;压测前检查 `ulimit -n`。
3. 将历史回查改为分页和限并发,或先关闭自动历史回查,仅验证实时阶段;实时阶段结束后再启动独立历史校验。
4. 修正报告字段,把超时/socket 明确归类为 `publish_unknown`,并把 104 条 unacked-observed 消息纳入历史校验。
5. 补充 `backend-debug` 对 `metrics.k8s.io/pods` 的只读权限,否则下一轮仍无法采集 Pod CPU/内存。
6. A 链路稳定后,使用相同 2000 连接、2000 条消息模型重新验收。门禁要求两侧断线为 0、明确拒绝为 0、实时到达率 100%、持久化丢失为 0,并分别报告实时阶段和历史阶段的队列指标。
## 7. 结果文件
本轮原始结果位于:
```text
loadtest/im-pressure/results/group2k-r4-20260928-01/
agent-a-summary.json
agent-b-summary.json
coordinator.json
report.json
pre-metrics.log
pod-metrics.log
post-metrics.log
```
## 8. 已实施改造
本分析完成后,压测工具已实施以下改造:
1. agent 分开输出 `publish_rejected` 和 `publish_outcome_unknown`;前者只包含服务端明确拒绝,后者包含 ACK 超时和 socket 失败。
2. A/B 在 drain 后向 coordinator 提交“实时已观测但未 ACK”的消息台账,coordinator 去重合并后将其加入历史落库核验。
3. 报告新增 `history_unacked_observed_checked` 和 `history_unacked_observed_found`,可直接确认结果未知消息是否最终落库。
4. r5 复测后,历史回查进一步调整为 1 个 worker、每页 20 条、全局每 500ms 最多发起一次查询,避免核验程序自身制造 Topic/Proxy 洪峰。
5. 新增 `backend-debug-metrics-rbac.yaml`,为测试环境的 `argocd/backend-debug` ServiceAccount 提供 `metrics.k8s.io/pods` 的 namespace 只读权限。
上述改造只解决统计口径、历史查询压力和资源指标权限。机器 A 的公网连接异常仍需通过下一轮主机与网络观测定位。
@@ -0,0 +1,92 @@
# IM 2000 人群聊 r5 复测分析
日期:2026-09-28
Run ID:`group2k-r5-20260928-01`
## 1. 结论
本轮再次证明服务端对稳定客户端的群消息可靠性没有丢失,失败集中在机器 A 的本地代理/TUN 链路:
- B:1000 次发送全部 ACK,1426000 次 ACK 台账内投递全部到达,断线、重复、乱序和实时缺失均为 0。
- A:574 次发布结果未知、657 次统计窗口内断线、464596 次 ACK 台账内实时缺失。
- A 的 broken pipe 全部写向 `198.18.0.81:443`;本机 DNS 也将 `api.118im.com` 解析为 `198.18.0.81`,路由经 `utun19827 -> 172.19.0.1`。`198.18.0.0/15` 是代理/TUN 常用 Fake-IP 网段,不是 API 的真实公网地址。
- 机器 A 压测进程 CPU 平均占用不高、内存峰值约 150MB,B 在同一服务端负载下完全稳定,不支持“服务端全局过载”解释。
- 新增观测台账生效:574 条结果未知中,241 条被实时观测并加入历史核验,241 条全部找到。
- 历史总核验 1667/1667,持久化丢失为 0。
- 历史回查仍产生 1197 次 `topic broadcast queue full`,但全部发生在历史阶段,不在实时发送阶段。
总体结论:**群消息落库通过,B 侧实时可靠性通过;A 侧 TUN/Fake-IP 链路失败;历史查询限速仍需继续降低。**
## 2. 汇总结果
| 指标 | A | B | 合计 |
|---|---:|---:|---:|
| READY | 1000 | 1000 | 2000 |
| 发送尝试 | 1000 | 1000 | 2000 |
| ACK | 426 | 1000 | 1426 |
| 明确拒绝 | 0 | 0 | 0 |
| ACK 超时 | 444 | 0 | 444 |
| socket 结果未知 | 130 | 0 | 130 |
| 断线 | 657 | 0 | 657 |
| ACK 台账内应收 | 1426000 | 1426000 | 2852000 |
| ACK 台账内实收 | 961404 | 1426000 | 2387404 |
| ACK 台账内实时缺失 | 464596 | 0 | 464596 |
| ACK 台账外额外投递 | 185738 | 241000 | 426738 |
| 历史核验 | 1667/1667 | 0/0 | 1667/1667 |
| 未 ACK 观测消息核验 | 241/241 | 0/0 | 241/241 |
服务端实际处理消息至少为 `1426 + 241 = 1667` 条。其余 333 条结果未知既没有 ACK,也没有任何实时观测证据,不能证明服务端接受。
## 3. A 机器故障证据
### 3.1 网络路径
测试结束后的本机检查结果:
```text
api.118im.com -> 198.18.0.81
route: 198.18.0.81 -> gateway 172.19.0.1, interface utun19827
broken pipe remote: 198.18.0.81:443
```
A 的错误构成为:
- `ctrl_timeout=444`
- `websocket_closed=103`
- `broken_pipe=27`
- 其他 1
连接从 `17:05:12 UTC` 开始下降,随后从 1000 持续降至约 333;B 同期保持 1000 连接。下一轮前必须关闭该代理/TUN,或至少将 `api.118im.com` 设置为 DIRECT,并确认 DNS 不再返回 `198.18.0.0/15` Fake-IP。
### 3.2 本机资源
- 进程 RSS 峰值约 150MB。
- 压测期间累计 CPU 增量约 75 秒,对约 625 秒窗口而言不构成 CPU 饱和。
- open FDs 在连接大量断开后仍约为 1011。`WSClient.readLoop` 在读失败返回时没有立即关闭底层连接,虽然进程结束时会统一关闭,但应修复运行期 FD 滞留。
## 4. 服务端和历史核验
实时发送和 drain 阶段没有新增以下错误:
- `sub.broadcast channel full`
- `cluster: proxy channel full`
- `topic's broadcast queue is full`
- Redis fanout publish failure
历史核验约在 `17:13:15 UTC` 开始。`im-server-2` 从 `17:13:22` 到 `17:14:14` 记录 1197 次群 Topic broadcast queue full。当前 1 worker、每页 50 条、100ms 间隔仍会让单次历史回放快速灌入容量为 64 的 Topic 队列。
尽管重试后 1667 条全部找到,历史核验不应持续制造队列丢弃。建议调整为:
- `history_page_size=20`
- `history_query_interval=500ms`
- `history_workers=1`
- 历史超时保持 3 分钟;1667 条约需 42 秒完成首轮查询。
## 5. 下一步
1. 修复 `WSClient.readLoop` 退出时未关闭底层连接的问题。
2. 将历史分页调整为 20 条、查询间隔 500ms。
3. 在 A 机关闭 TUN/代理,或配置 `api.118im.com` DIRECT;起压前检查 DNS 和路由不再进入 `198.18.0.0/15`、`utun*`。
4. 测试环境尚未应用 metrics RBAC,`kubectl auth can-i list pods.metrics.k8s.io` 仍返回 `no`,因此本轮没有 Pod CPU/内存数据。需要将 `backend-debug-metrics-rbac.yaml` 加入实际 ArgoCD overlay。
5. 完成上述三项后再运行同规模 r6。若 A/B 都稳定,再根据 B 本轮 ACK P95 约 1.14s、同机投递 P95 位于 2s 桶的结果继续做服务端延迟优化。
@@ -0,0 +1,93 @@
# IM 2000 人群聊 r6 复测分析
日期:2026-09-28
Run ID:`group2k-r6-20260928-01`
## 1. 结论
r6 比 r5 有改善,但仍未通过。A 去掉 Fake-IP 后 ACK 数从 426 增加到 758,统计窗口断线从 657 降到 213;然而 `api.118im.com` 的真实公网 IP 仍经 `utun4`,A 在正式开始前就发生 28 次连接抖动,正式开始后继续成批断线。后续排查确认该 `utun4` 由 OpenVPN Connect 的全局路由持有,而不是 LightXtreme。B 也出现 2 次断线和 1 次发布结果未知,不再是完全零错误。
历史落库仍然可靠:ACK 台账和未 ACK 观测台账合计核验 1870/1870,持久化丢失为 0。FD 修复已验证生效,A 的存活连接从 1000 降至 787 时,open FDs 从约 1013 同步降至约 799,不再滞留断开的 socket。
历史限速和退出行为仍会污染服务端队列指标:历史查询阶段约产生 436 次 Topic 队列满;A 结束时一次性关闭大量群订阅连接,又集中产生约 598 次队列满。
总体结论:**落库正确性通过;FD 修复通过;实时可靠性失败;A 的 OpenVPN/TUN 链路仍未绕过;历史回放与集中断连仍需改进。**
## 2. 汇总结果
| 指标 | A | B | 合计 |
|---|---:|---:|---:|
| READY | 1000 | 1000 | 2000 |
| 发送尝试 | 1000 | 1000 | 2000 |
| ACK | 758 | 999 | 1757 |
| ACK 超时 | 122 | 0 | 122 |
| socket/连接结果未知 | 120 | 1 | 121 |
| 实际明确拒绝 | 0 | 0 | 0 |
| 断线 | 213 | 2 | 215 |
| ACK 台账内应收 | 1757000 | 1757000 | 3514000 |
| ACK 台账内实收 | 1468873 | 1756514 | 3225387 |
| ACK 台账内实时缺失 | 288127 | 486 | 288613 |
| ACK 台账外额外投递 | 94965 | 112984 | 207949 |
| 历史核验 | 1870/1870 | 0/0 | 1870/1870 |
| 未 ACK 观测消息核验 | 113/113 | 0/0 | 113/113 |
服务端实际处理消息至少为 `1757 + 113 = 1870` 条。其余 130 条结果未知没有 ACK 或实时观测证据,不能证明服务端接受。
## 3. 报告口径修正
合并报告当前显示 91 次明确拒绝和 152 次结果未知,这是错误分类:
- A 的 91 次 `use of closed network connection` 被归类为 `other`。
- 该错误是连接关闭后的写失败,应该归类为 `socket` 结果未知。
- 修正后本轮明确拒绝为 0,结果未知为 243。
此外,群历史只由 A 执行。A 已证明全部 1757 条 ACK 消息落库,但 B 的 `realtime_lost_but_persisted` 无法读取 A 的历史结果,因此 B 的 486 次实时缺失没有计入“实时缺失但已落库”。合并报告需要共享全局历史核验结果后再计算该字段。
## 4. A 网络与 FD
本轮 DNS 返回真实地址:
```text
64.118.158.131
216.236.31.189
216.236.31.191
```
但到真实地址的系统路由仍为:
```text
gateway: 172.27.240.1
interface: utun4
```
这说明 Fake-IP 已关闭,但流量仍进入 VPN/TUN。A 在 barrier 提交前持续掉线重连,正式开始后约 50 秒开始掉线,最终只剩约 787 个连接。后续确认 OpenVPN Connect 下发了 `0/1` 和 `128.0/1` 两条全局路由;LightXtreme 的 DIRECT 规则无法绕过另一套 OpenVPN TUN。r7 前已为 `api.118im.com` 当前三个 IP 添加经 `192.168.1.1/en1` 的 `/32` 主机路由,保留 OpenVPN 内网访问的同时让压测 HTTP/WS 直连。
FD 时间序列证明 read loop 修复有效:
- READY 1000 时 open FDs 约 1013。
- READY 901 时 open FDs 约 913。
- READY 787 时 open FDs 约 799。
连接每减少一个,FD 基本同步减少一个。
## 5. Topic 队列满
实时发送和 drain 阶段仍没有 Topic/Proxy 满队列。`im-server-2` 的 1034 次 `topic's broadcast queue is full` 分为:
- `00:35:59–00:36:11 UTC`:约 436 次,发生在历史回查阶段。
- `00:37:19 UTC`:约 598 次,发生在 A agent 完成后批量关闭连接阶段。
20 条/500ms 虽有改善,但 Topic 历史数据仍以突发方式回送。建议:
1. 历史每页进一步降至 10 条,查询间隔保持 500ms。
2. 每页请求后等待本页目标消息全部收到或超时,再发送下一页,而不是只按固定时间节流。
3. agent 退出时按 10–20ms 间隔分批关闭连接,避免 1000 个群订阅同时离线。
## 6. 后续修复
1. 已将 `use of closed network connection` 分类为 socket 结果未知。
2. 已在 coordinator 共享两机历史命中 ID,使 B 也能正确计算实时缺失但已落库。
3. 历史回查已改为每页 10 条:同一 Topic 每页完成确认后再请求下一页,不同 Topic 最多 12 路并行。
4. agent 已改为在 10 秒窗口内渐进关闭 WebSocket。
5. r7 开始前重新解析 `api.118im.com`,确认每个 IP 都通过 `/32` 主机路由走 `en1`;OpenVPN 重连、DNS 变更或机器重启后必须重新核验。
@@ -297,3 +297,219 @@ Run ID:`p2p-200-20260927-01`,A/B 两台压测机各模拟 200 个用户,
- ACK P95 和到达 P95 均落在 2 秒直方图上界,未达 500ms / 1s 门槛。
- ArgoCD 已将 StatefulSet 资源回滚为 `request 100m/128Mi`、`limit 500m/256Mi`,且 liveness 仍使用 `/healthz`。压测期间存在 CPU throttling 累计,部署配置必须在 GitOps 声明源中固化,否则应用后仍会被回滚。
- 403 修复发布后应重跑 200 对;只有 ACK 成功率、实时到达率、MySQL 精确落库均达标,才进入 500 对档位。
## 12. 200 对第三轮复测结果
Run ID:`p2p-200-20260927-03`。压测前修复了压测工具复用固定 `Idempotency-Key` 而跳过好友关系校验的问题,并重新核对、修复 200 对测试账号。
| 指标 | 结果 |
|---|---:|
| WS Ready | 400/400 |
| Publish 尝试 / ACK | 4000 / 4000 |
| Publish 拒绝 / 403 / 502 / 503 | 0 / 0 / 0 / 0 |
| ACK 后应到达 / 实际到达 | 4000 / 4000 |
| 实时到达率 | 100% |
| 历史查询 | 4000/4000 |
| MySQL 精确匹配 | 4000/4000 |
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 |
| 丢失 / 重复 / 断连 | 0 / 0 / 0 |
| 乱序 | 1 |
| Cluster 队列采样最大值 | 0 |
| Kafka 峰值 lag / 最旧 backlog age / 最终 lag | 3028 / 92.624s / 0 |
结论:跨 Pod 发送、ACK 后实时投递、Kafka 最终消费和 MySQL 落库的数据正确性已通过 200 对验证。结果报告仍为不通过,原因是 B 端记录 1 次单连接 Topic seq 回退,以及 ACK P95 和投递 P95 均落入 1-2s 区间。
资源采样显示,三个 Pod 峰值内存低于 94MiB,但 Pod 0/1/2 在本轮窗口内的 CPU throttled 增量约为 7.62s / 0.78s / 8.51s。实际 StatefulSet 仍是 `request 100m/128Mi`、`limit 500m/256Mi`,且 liveness 仍是 `/healthz`。因此延迟和 Kafka 追平速度尚不能作为扩容到 500 对的验收基线。
已增加 `deploy/argocd/im-server-test/statefulset-performance-patch.yaml`,要求 ArgoCD 声明源固化 `request 1C/512Mi`、`limit 2C/1Gi`和 `/livez` liveness。部署流水线改为等待 ArgoCD 回写后严格校验,如果再被回滚则直接失败。压测工具保留乱序为失败门禁,并新增最多 100 条 `receiver/topic_hash/previous_seq/current_seq/client_mid` 明细,用于下轮精确定位。
## 13. GitOps 资源生效后的第四轮复测
Run ID:`p2p-200-20260927-04`。ArgoCD Application `im-server-test` 的真实配置仓库已固化 `request 1C/512Mi`、`limit 2C/1Gi` 和 `/livez`,三个 Pod 完成滚动后均为 Ready、零重启。
| 指标 | 结果 |
|---|---:|
| WS Ready | 400/400 |
| Publish 尝试 / ACK | 4000 / 4000 |
| Publish 拒绝 / 403 / 502 / 503 | 0 / 0 / 0 / 0 |
| ACK 后应到达 / 实际到达 | 4000 / 4000 |
| 实时丢失 / 重复 / 乱序 / 断连 | 0 / 0 / 0 / 0 |
| 历史查询 | 4000/4000 |
| MySQL 精确匹配 | 4000/4000 |
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 |
| 精确 ACK P50 / P95 / P99 / Max | 368.189 / 566.683 / 725.654 / 1600.975 ms |
| 投递 P95 直方图上界 | 1000 ms |
| Cluster 队列采样最大值 | 0 |
| Kafka message persist 峰值 lag / 归零耗时 | 2946 / 发送结束后 113s |
| Pod 0/1/2 CPU throttled 增量 | 0 / 0 / 0 usec |
| Pod 0/1/2 峰值内存 | 87.86 / 80.80 / 85.18 MiB |
结论:消息正确性全部通过,第三轮出现的 1 条 seq 回退没有复现。资源扩容完全消除了本轮 CPU throttling,证明旧的 500m CPU limit 是延迟放大因素之一;精确 ACK P95 从第三轮的 1006.288ms 降至 566.683ms,下降约 43.7%,但仍比 500ms 门槛高约 13.3%,因此报告只因 ACK 延迟门禁判定失败。
Kafka `im-server.message_persist` lag 在发送后峰值为 2946,并在发送结束后约 113 秒归零,超过 60 秒目标。历史 API 和 MySQL 最终均确认 4000 条完整落库,说明这是落库延迟而不是消息丢失。进入 500 对测试前,应优先检查 message persist consumer 的批量写入、worker/partition 并行度和 MySQL 写入耗时。
连接集中关闭期间,三个 Pod 分别记录 10、14、22 次 `failed to close login audit ... Deadlock found`,共 46 次。该问题未影响消息表,但会导致登录审计结束状态不完整。登录审计关闭更新应增加 MySQL 1213/1205 有界重试,并检查更新条件和索引以缩小锁范围。
## 14. 持久化并行与审计死锁修复后的第五轮复测
Run ID:`p2p-200-20260927-05`。本轮已将 `im.message.persist` 和 `im.group.message.persist` 固化为 3 分区、3 副本;同一 Topic 继续作为 Kafka key,保证会话内顺序。服务端同时按 batch 内最大 Seq 合并 Topic 和发送者已读位点更新,并为登录审计关闭增加复合索引和 MySQL 死锁重试。
| 指标 | 结果 |
|---|---:|
| WS Ready | 400/400 |
| Publish 尝试 / ACK | 4000 / 4000 |
| ACK 后应到达 / 实际到达 | 4000 / 4000 |
| 丢失 / 重复 / 乱序 / 断连 | 0 / 0 / 0 / 0 |
| 历史查询 / MySQL 精确匹配 | 4000/4000 / 4000/4000 |
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 |
| 总体精确 ACK P50 / P95 / P99 / Max | 375.314 / 547.516 / 670.317 / 2845.347 ms |
| A 机精确 ACK P95 | 408.155 ms |
| B 机精确 ACK P95 | 594.533 ms |
| Pod 0/1/2 Kafka 峰值 lag | 676 / 251 / 143 |
| 最慢 Kafka 分区归零时间 | 发送结束后约 52.5s |
| Cluster 队列峰值 / CPU throttled 增量 | 0 / 三 Pod 均为 0 |
| 登录审计关闭死锁 | 三 Pod 均为 0 |
| Kafka batch failure / Pod 重启 | 0 / 0 |
结论:三分区已由三个 Pod 实际并行消费,单 Pod 峰值 lag 从第四轮的 2946 降至 676,最慢消费者在 60 秒门槛内归零。消息正确性、最终落库、会话内顺序、CPU 限流和登录审计死锁均通过。
报告仍因 ACK P95 门禁显示失败。直方图只能给出 1000ms 上界,按 4000 条 ACK 原始时间戳和 nearest-rank 口径计算的实际 P95 为 547.516ms。A 机已经低于 500ms,而 B 机为 594.533ms,且 B 机存在 2.845s 最大长尾;两台机器使用相同公共入口和相同服务端版本,因此继续修改服务端前,应先采集 B 机到 `api.118im.com` 的 DNS、TCP、TLS、WebSocket RTT 与时钟偏差,并使用定向 Pod 入口做 A/B 对照,区分公网链路长尾和服务端处理耗时。
## 15. 500 对第一轮容量测试
Run ID:`p2p-500-20260927-01`。A/B 各建立 500 条 WebSocket,共 500 对、1000 条连接和 10000 次 Publish。正式发压前补齐了第 201–500 对中缺失的 9 对好友权限,两端预检和 Ready 均通过。
| 指标 | 结果 |
|---|---:|
| WS Ready | 1000/1000 |
| Publish 尝试 / ACK | 10000 / 9946 |
| Publish 失败 | 54(44 条服务端 500/other,10 条 socket error) |
| Publish 成功率 | 99.46% |
| ACK 后应到达 / 实际到达 | 9946 / 9936 |
| 实时丢失 / 重复 / 乱序 / 断连 | 10 / 0 / 0 / 1 |
| 实时到达率 | 99.8995% |
| 总体精确 ACK P95 / Max | 2429.954 / 3636.045 ms |
| 压测期历史回查 | 8580/9946 |
| Kafka 完全排空后 MySQL 精确匹配 | 9946/9946 |
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 |
| Pod 0/1/2 Kafka 峰值 lag | 2490 / 3234 / 2745 |
| Kafka 归零时间 | 发送结束后约 4 分钟 |
| Pod 0/1/2 峰值内存 | 148.1 / 130.5 / 122.4 MiB |
| Pod 0/1/2 CPU throttled 增量 | 0 / 0 / 0 |
| Pod 重启 | 0 |
本轮不通过,不得升级到 1000 对。根因是 MySQL 连接容量与 IM Server 连接池上限不匹配:
- MySQL `max_connections=170`,本轮 `Max_used_connections=172`,`Connection_errors_max_connections=27`。
- IM Server 每 Pod `max_open_conns=64`,3 个 Pod 的理论连接池上限已达 192,还未计入 admin server、其他服务和运维连接。
- 本轮 Pod 0/1 分别记录 153/19 次 `Error 1040: Too many connections`,包括 50 次 `failed to save message`和 44 次 P2P 关系检查失败;这些错误与 44 条服务端 500 同时出现。
- A 机用户 190 的 WebSocket 断开,产生 10 次 socket error,并导致对端已 ACK 的 10 条消息未能实时投递到该连接。
- 压测结束阶段 Pod 2 记录 282 次群 Topic broadcast queue full,发生在大量会话同时清理期,需要与消息发送链路分开处理。
压测阶段的 `history_found=8580`不是最终丢库:历史回查结束时 Kafka 仍在积压,等 lag 归零后从 Pod 内使用 ACK ledger 独立查询 `im_008_messages`,9946 条全部精确匹配。因此当前是“已接受消息最终零丢失,但持久化延迟超标”。
下一轮 500 对复测前必须先完成:
1. 统一规划 MySQL 全局连接预算,覆盖 IM Server、admin server 及其他共库服务;单个服务的连接池上限不能独立设置到总库容量。
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`代替配置源。
## 16. 500 对连接池改造后复测
Run ID:`p2p500-fix-20260927-02`。A/B 各建立 500 条 WebSocket,共 500 对、1000 条连接和 10000 次 Publish。三个 IM Pod 均已使用 `meeting_im=24/8`、`hotgo=4/2` 的连接池配置。
| 指标 | 改造前 | 本轮 |
|---|---:|---:|
| WS Ready | 1000/1000 | 1000/1000 |
| Publish ACK | 9946/10000 | 10000/10000 |
| 服务端 500 / socket error | 44 / 10 | 0 / 0 |
| 实时到达 | 9936/9946 | 10000/10000 |
| 实时丢失 / 重复 / 乱序 / 断连 | 10 / 0 / 0 / 1 | 0 / 0 / 0 / 0 |
| ACK P50 / P95 / P99 / Max | - / 2429.954 / - / 3636.045 ms | 662.062 / 1567.569 / 2620.878 / 3156.025 ms |
| 压测期历史回查 | 8580/9946 | 6738/10000 |
| Kafka 排空后 MySQL 精确匹配 | 9946/9946 | 10000/10000 |
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 | 0 / 0 / 0 / 0 |
| Pod 0/1/2 Kafka 峰值 lag | 2490 / 3234 / 2745 | 2259 / 2875 / 2326 |
| Kafka 最慢归零 | 约 4 分钟 | 发送结束后约 4 分 30 秒 |
| MySQL 1040 | 172 次 Pod 日志记录 | 0 |
| Pod 重启 | 0 | 0 |
结论分为两部分:
1. 入口和实时正确性修复有效。10000 次 Publish 全部被接受,实时投递、最终落库、顺序、重复和断连门禁全部通过;连接池上限把全局 MySQL 连接耗尽转化成了进程内排队,没有再出现 `Too many connections`。
2. 容量门禁仍未通过,暂不升级到 1000 对。ACK P95 仍为 1567.569ms,Kafka 未在 60 秒内归零,压测期历史回查因落库未追平只看到 6738 条。
本轮新增指标进一步定位出两个瓶颈:
- 发送阶段 `hotgo` 注册库连接池出现 5841 次新增等待,累计等待约 210.25 秒。`TenantAdapterFor` 当前在命中已创建的租户 adapter 之前仍执行 `tenantByID`,使共库路由的高频操作重复查询 `im_tenant`;4 条注册库连接因此成为 ACK 热路径瓶颈。
- 发送结束 30 秒后,两台 agent 同时以默认 48 worker 执行历史核验,形成 96 路历史查询。三个 `meeting_im` 连接池随后全部达到 24/24;主库新增 6780 次连接等待,累计等待约 11010.16 秒。历史查询与 Kafka persistence 共用连接池,直接延长了 lag 排空时间。
- Pod 2 有一个 100 条批次因 `context deadline exceeded` 降级为逐条写入,100 条最终均成功;三个 Pod 共记录 8 个数据库超时相关日志,没有死信,最终数据库精确核验为 10000/10000。
下一轮前应完成:
1. 为租户元数据增加带失效机制的进程内缓存;`TenantAdapterFor` 命中有效缓存时不得每次读取 `hotgo.im_tenant`,租户停用或配置变更通过现有配置事件主动失效,并保留短 TTL 兜底。
2. 压测程序把实时发压和持久化核验拆成两个阶段:先观察 Kafka 自然排空,再以每机 8–16 个 worker 执行历史核验,避免核验流量反向干扰被测落库吞吐。同时另设“历史读取并发压测”场景验证读写混合容量。
3. persistence 与在线历史查询至少增加独立并发预算;若继续共用连接池,应为 persistence 保留连接或限制历史查询并发,避免读请求耗尽全部 24 条连接。
4. 完成上述改造后仍以 500 对复测,门禁保持 ACK 10000/10000、实时错误为 0、Kafka 60 秒内归零、最终数据库 10000/10000,再决定是否升级到 1000 对。
### 16.1 路由缓存和分阶段核验改造
2026-09-27 已完成下一轮复测所需代码调整:
- MySQL `TenantAdapterFor` 增加 1 分钟租户路由有效期;有效期内直接复用 tenant adapter,不再读取 `hotgo.im_tenant`。
- 同一租户的缓存刷新使用 singleflight 合并,TTL 到期瞬间最多执行一次注册库查询,避免并发击穿。
- `tenant.updated`、`tenant.status.changed`、`tenant.limits.updated`、`tenant.features.updated` 和 `cache.tenant.invalidate` 继续使用现有 Kafka 广播事件;`InvalidateTenantConfigCache` 现在同时使数据库路由缓存失效。失效与刷新并发时通过 generation 校验阻止旧查询重新写入有效缓存。
- 压测配置新增 `history_settle`,默认 5 分钟。实时 drain 完成后先等待该窗口,再发起历史查询,从而单独测量 Kafka 自然排空时间。
- 历史查询默认并发从每机 48 降为 12;历史读取高并发能力后续使用独立读写混合场景验证,不再污染消息落库吞吐结果。
## 17. 路由缓存发布后 500 对复测与延迟改造
Run ID:`p2p500-r3-20260927-03`。A/B 各 500 条 WebSocket、每人发送 10 条消息,总计 10000 次 Publish。
| 指标 | 结果 |
|---|---:|
| WS Ready | 1000/1000 |
| Publish ACK | 10000/10000 |
| 实时到达 / 丢失 | 10000/10000 / 0 |
| 历史回查 / 落库丢失 | 10000/10000 / 0 |
| 重复 / 乱序 / 断连 | 0 / 0 / 0 |
| 总体精确 ACK P95 | 1308.150 ms |
| A / B 精确 ACK P95 | 1121.2 / 1519.2 ms |
| 原报告投递 P95 | 2000 ms(受双机时钟偏差污染) |
| Kafka 最终 lag | 0 |
| `hotgo` pool wait | 0 |
租户路由缓存已经消除 `hotgo` 热路径等待,消息正确性和最终落库全部通过。剩余 ACK 延迟集中在消息 4–6 的突发积压阶段。发压窗口内,`meeting_im` 连接池出现明显等待;同时 Kafka persistence consumer 正在使用同一连接池。Cluster Route RPC 平均约 1–2ms,P2M 队列始终为 0,因此跨 Pod RPC 不是秒级延迟来源。
原投递 P95 不能作为服务端结论:压测程序使用发送机绝对时间戳在接收机计算单向耗时。A 只接受了 5777 个非负样本,而 B 接受了全部 10000 个样本,说明 B 时钟领先 A;A 丢弃负值,B 则把时钟偏差计入延迟。
本次完成以下改造:
1. 压测程序新增同机与跨机两个投递直方图;`delivery_local_p95_ms` 作为门禁,跨机负延迟计入 `cross_machine_negative_latency_samples`,不再静默丢弃后继续生成误导性 P95。
2. 压测消息显式携带 `sender_machine`,旧消息仍可从 `client_mid` 兼容识别来源。
3. 好友关系首次数据库确认后双向回填 Redis,减少双向聊天后续回源。
4. 新增 `im_message_publish_stage_duration_seconds{kind,stage,result}`,覆盖 `relationship_check`、`redis_idempotency`、`redis_sequence`、`kafka_produce` 和整体 `save` 阶段。
5. 新增 `log_client_packets` 配置,默认关闭逐包 INFO 日志,避免每条消息额外 JSON 解析和容器 stdout I/O;需要诊断时才短期开启。
下一轮仍使用 500 对复测。重点比较 `relationship_check`、`kafka_produce` 和 `save` 的 P95,并以 `delivery_local_p95_ms` 判断投递延迟;跨机延迟只有在 chrony/NTP 偏差确认小于 5ms 后才用于验收。数据库连接池暂不扩大,避免再次超过 MySQL 全局 170 连接预算;若 `relationship_check` 已稳定但 pool wait 与 Kafka lag 仍持续增长,再设计 persistence 独立连接预算或批量更新方案。
@@ -357,6 +357,14 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 删除单条/多条消息 | `{"del":{"id":"del-1","topic":"grp...","what":"msg","delseq":[{"low":12,"hi":13}],"hard":false}}` | `{"ctrl":{"id":"del-1","code":200,"text":"ok","params":{"del":3}}}`;其他端可能收到 `{"pres":{"topic":"grp...","what":"del","seq":3}}` |
| 清空当前用户视图 | `{"del":{"id":"del-all-1","topic":"p2p...","what":"msg","delseq":[{"low":1,"hi":126}],"hard":false}}` | `{"ctrl":{"id":"del-all-1","code":200,"text":"ok","params":{"del":4}}}` |
**单聊已读/已收回执协议约定(服务端已验证,2026-09-29):**
1. **在线下行形态**:`info.topic` 为话题名(如 `p2p...`),`info.from` 为报读者,`info.what` 为 `recv`/`read`,`info.seq` 为水位。服务端跳过上报会话自身,推送给对端及本人其他在线设备(多 Pod 部署经 Kafka 同步,各 Pod 均会下发)。
2. **离线补发形态**:接收方离线时收到 `{"info":{"topic":"me","src":"p2p...","from":"usr...","what":"recv","seq":12}}`,即 `topic` 固定为 `me`、`src` 才是话题名。客户端必须同时兼容上述两种形态。
3. **序号单调递增**:服务端丢弃 `seq` 不大于当前水位的回执(`read`、`recv` 各自独立判断),`read` 回执会同步抬升 `recv`,恒有 `read ≥ recv`。客户端渲染一律取服务端最大游标,禁止用本地计数覆盖服务端值。
4. **断线重连恢复**:`sub`/`get desc` 响应的 `desc` 中包含 `seq`、`read`、`recv`、`clear` 四个游标;`get {"what":"sub"}` 也返回各成员的 `read/recv`。重连后以这些游标恢复本地状态,不要依赖进程内存。
5. **换设备登录**:回执水位按「用户 × 话题」在服务端持久化,与设备无关;新设备订阅后 `desc` 同时返回自己与对端的最新 `read/recv`,可完整恢复对方"已读/已收"状态。发送者自身的 `read/recv` 由服务端自动推进到其最新发出的 `seq`。
##### 0.5.4.5 Topic 资料、个人群设置和标签
| 场景 | 客户端发送 | 服务端返回/下行示例 |
@@ -1105,7 +1113,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
- 选择媒体后本地生成 `client_mid` 和稳定占位;调用文件上传,得到 `file_id` 后再 `pub x-im-type=image|video|voice`。
- `content` 至少含 `file_id,name,size,mime,width,height,duration_ms,thumbnail_file_id` 中适用字段;上传进度是客户端任务状态,不写成聊天消息。
- 服务端发布成功后关联文件与 Topic/消息;文件下载必须校验消息可见性。上传失败只重试上传,成功但 `pub` 失败时复用已上传 `file_id` 和 `client_mid`。
- 服务端发布成功后关联文件与 Topic/消息;文件下载必须校验消息可见性。
- 上传失败(网络中断、超时、5xx)按**整文件重传**处理:重新调用 `POST /v0/file/u/` 上传完整文件,不做分片/断点续传;每次重传得到新 `file_id`,旧 `file_id` 直接放弃、由服务端 TTL 回收,客户端不得再引用。
- 上传成功但 `pub` 失败时不重新上传,复用已上传 `file_id` 和原 `client_mid` 重发消息,服务端按 `client_mid` 幂等去重。
### 3.7 个人对话—拉黑状态/已读状态.png
@@ -1259,7 +1269,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:客户端任务管理新增;服务端上传需改造。**
- 每个待发送项保存 `client_mid,local_uri,type,upload_id,progress,state`;同一文件可分片/断点续传时使用稳定 `upload_id`。
- 每个待发送项保存 `client_mid,local_uri,type,upload_id,progress,state`;`upload_id` 仅在单次上传任务内有效(进度上报与本地去重用)。
- 重试即新建上传任务:`progress` 归零、生成新 `upload_id`,**整文件重传** `POST /v0/file/u/`,不做分片/断点续传;重传成功后消息体 `file_id` 等字段全部取最新一次上传响应。
- 自动重试采用指数退避且不超过 3 次,之后转人工点按重试;App 冷启动扫描本地 `failed` 任务队列重新上传。
- 上传完成后再发布消息;多任务可并发但必须各自保持原排序号。离开页面不取消,退出账号必须暂停并隔离任务。
- 服务端返回 `FILE_TOO_LARGE`、`FILE_TYPE_NOT_ALLOWED`、`STORAGE_QUOTA_EXCEEDED` 时不可自动无限重试。
@@ -1374,10 +1386,10 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/conversations/{topic}/context` | Path `p2p...` 或 `grp...` | `{"topic":"p2p...","type":"p2p","relationship":"friend","can_send":true,"capabilities":{"text":true,"image":true,"file":true,"voice":true,"can_call":true},"peer":{"user_id":"usr...","display_name":"张三","avatar_file_id":""}}` |
| `POST /v1/im/conversations/p2p` 待实现 | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` |
| `GET /v1/im/conversations/{topic}/settings` 待实现 | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` |
| `PATCH /v1/im/conversations/{topic}/settings` 待实现 | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` |
| `GET /v1/im/conversations/{topic}/messages` 待实现 | Query `?since=12&limit=20` | `{"topic":"p2p...","items":[{"seq":13,"from":"usr...","ts":"2026-08-20T09:00:00Z","head":{"x-im-type":"text"},"content":{"text":"你好"}}],"next_cursor":""}` |
| `POST /v1/im/conversations/p2p` | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` |
| `GET /v1/im/conversations/{topic}/settings` | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` |
| `PATCH /v1/im/conversations/{topic}/settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` |
| `GET /v1/im/conversations/{topic}/messages` | Query `?since=12&limit=20` | `{"topic":"p2p...","items":[{"seq":13,"from":"usr...","ts":"2026-08-20T09:00:00Z","head":{"x-im-type":"text"},"content":{"text":"你好"}}],"next_cursor":""}` |
| `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/topics/{topic}/messages/{seq}/recall` | 单条撤回;`topic` 可传 `p2p...` 或 `grp...`;Body `{}`,可选 `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/topics/{topic}/messages/recall` | 多选批量撤回;`{"items":[{"seq":12,"version":1},{"seq":13}],"reason":"mistake"}`;`items` 1~50 条,`version` 可选 | `{"topic":"grp...","items":[{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"},{"seq":13,"status":"failed","http":409,"code":30006,"reason":"VERSION_CONFLICT","message":"message version conflict"}]}` |
@@ -1553,6 +1565,8 @@ Body:
撤回、编辑属于 `seq` 不变的原地变更。在线且已订阅 Topic 时,客户端会收到 `message.recalled` / `message.edited` 实时事件;离线或未订阅时,需要靠会话级水位补偿。
单聊场景中,客户端在线但未订阅该 P2P 时,服务端还会向双方用户通道下发 `message.mutation` 脏标记事件(`what:"im"`、`event:"message.mutation"`,payload 含 `topic/seq/version/event`),客户端收到后同样按下方水位补偿流程拉取;群聊不下发脏标记,仅靠会话列表水位 + 拉取补偿。
会话列表同步时,`me.meta.sub[].private` 会带:
```json
@@ -1606,6 +1620,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `mutation_version` 是会话级水位,不是单条消息的 `version`。
- 本地只做 `服务端 mutation_version > 本地 mutation_version` 比较,不要假设连续递增。
- 补偿接口返回的是最新 `limit` 条(默认 50、上限 100)变更的**状态快照**,非严格增量;变更条数超过 `limit` 时更早的变更不再返回。撤回窗口 120 秒、编辑窗口 300 秒,超窗变更无需客户端处理。
- 拉到 `message.recalled` 后,将本地 `topic + seq` 标记为撤回,并只在 `version` 更高时覆盖。
- 拉到 `message.edited` 后,按 `seq + version` 拉取或覆盖消息内容。
- 实时事件和补偿接口可能重复到达,客户端必须按 `topic + seq + version` 幂等处理。
@@ -2100,7 +2115,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"..."}` 建立群文件记录。
- 服务端校验 `member_upload_files`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。
- 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。
- 上传失败按整文件重传重新上传并取得新 `file_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。
### 6.29 接口示例
@@ -2127,7 +2142,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` |
| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","role":"member","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","permissions":{"can_mention_all":true}}` |
| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` |
| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...`;`user_id` 可传本人,返回 `relationship:"self"`、`capabilities.can_send=true` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` |
| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group":{"topic":"grp...","name":"项目群","member_count":2},"group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` |
| `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` |
| `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` |
+1 -1
View File
@@ -36,6 +36,7 @@ require (
go.mongodb.org/mongo-driver v1.17.9
golang.org/x/crypto v0.53.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.21.0
golang.org/x/text v0.38.0
google.golang.org/api v0.287.0
google.golang.org/grpc v1.82.0
@@ -139,7 +140,6 @@ require (
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
+18 -15
View File
@@ -8,37 +8,39 @@
## 当前 allrich 测试批次
以下参数适用于 2026-09-27 正在执行的第一轮单聊压测:
以下参数适用于 2026-09-27 的 2000 人群聊压测:
| 参数 | 值 |
|---|---|
| Git 分支 | `dev` |
| 最低提交 | `6d17154` |
| 最低提交 | `ea8732e` |
| 企业码 | `allrich` |
| HTTP | `https://api.118im.com` |
| WebSocket | `wss://api.118im.com/v0/channels` |
| 机器 A 内网地址 | `192.168.1.135` |
| Coordinator | `http://192.168.1.135:8090` |
| Run ID | `p2p-20260927-01` |
| 场景 | `p2p` |
| 机器 A 状态 | `1000/1000 READY`,等待机器 B |
| Run ID | `group2k-r1-20260927-01` |
| 场景 | `group` |
| 机器 A 状态 | 等待启动 coordinator 和 agent A |
机器 A 已生成安全运行包:
```text
/tmp/allrich-machine-b-runtime-p2p-20260927-01.zip
SHA-256: 479f119e7d01e1829c7262cace282c72a5f53ad8e7f5128abc2ade75d046db64
/tmp/group2k-r1-20260927-01.zip
SHA-256: 21e65b19476e64348b9214322021d880b231de1f9c9f685ea43d0082103e9cd1
```
运行包文件名固定为 `/tmp/<RUN_ID>.zip`。它包含被 Git 忽略的密钥和 Token,因此 `git pull` 不会下载该文件,必须从机器 A 的 `/tmp` 目录单独复制。
运行包内只有三个运行时文件:`.env.test`、`config.json`、`manifest.json`。其中包含 API Key、测试账号密码、固定测试验证码和账号 Token,不进入 Git,必须通过受控内网传输。测试环境验证码为 `123456`,正式环境禁止使用固定验证码。
因此,机器 B 只执行 `git pull` 后看不到这三个文件是正常现象。机器 B 需要从机器 A 单独复制运行包:
```bash
scp youshunlai@192.168.1.135:/tmp/allrich-machine-b-runtime-p2p-20260927-01.zip /tmp/
scp youshunlai@192.168.1.135:/tmp/group2k-r1-20260927-01.zip /tmp/
shasum -a 256 /tmp/allrich-machine-b-runtime-p2p-20260927-01.zip
# 必须输出:479f119e7d01e1829c7262cace282c72a5f53ad8e7f5128abc2ade75d046db64
shasum -a 256 /tmp/group2k-r1-20260927-01.zip
# 必须输出:21e65b19476e64348b9214322021d880b231de1f9c9f685ea43d0082103e9cd1
```
如果机器 B 无法主动连接 A,也可以由机器 A 执行 `scp` 推送;目标机器的用户名和地址由 B 机提供。禁止通过 Git、公开网盘或无访问控制的 HTTP 服务传输该运行包。
@@ -76,7 +78,7 @@ ulimit -n 100000
在机器 B 的仓库根目录执行:
```bash
unzip -o /tmp/allrich-machine-b-runtime-p2p-20260927-01.zip \
unzip -o /tmp/group2k-r1-20260927-01.zip \
-d loadtest/im-pressure
chmod 600 \
loadtest/im-pressure/.env.test \
@@ -108,7 +110,7 @@ source loadtest/im-pressure/.env.test
set +a
export MACHINE_A_IP='192.168.1.135'
export RUN_ID='p2p-20260927-01'
export RUN_ID='group2k-r1-20260927-01'
./loadtest/im-pressure/im-pressure verify \
-config loadtest/im-pressure/config.json \
@@ -144,9 +146,10 @@ mkdir -p "loadtest/im-pressure/results/${RUN_ID}"
1. 机器 B 建立 1000 条 WebSocket 连接并订阅对应 P2P Topic 和群 Topic。
2. agent 向机器 A coordinator 报告 READY。
3. 如果机器 A 尚未 READY,当前命令会等待,这是正常现象。
4. coordinator 下发 `run_id`、场景和统一 UTC 开始时间。
5. agent 完成发送、WS 到达统计、drain 和历史持久化回查。
6. agent 写出 summary、上报 coordinator,然后正常退出。
4. 等待期间连接断开会自动重连并重新订阅;在 A/B 都实时保持 1000 条连接并完成最终屏障前,agent 不会退出或开始发压。
5. coordinator 下发 `run_id`、场景和统一 UTC 开始时间。
6. agent 完成发送和 WS 到达统计后,先向协调器提交未 ACK 但已观测的消息台账;等待 `history_settle`(默认 5 分钟)后,以同 Topic 串行、不同 Topic 并行的限速分页方式执行历史持久化回查。等待期间不要停止进程。
7. 两机历史回查完成后会通过 coordinator 汇总命中消息,B 也会据此计算 `realtime_lost_but_persisted`;随后 agent 在默认 10 秒窗口内渐进关闭连接、写出 summary 并正常退出。
机器 B 不需要传场景参数。`p2p`、`group` 或 `mixed` 由机器 A coordinator 决定。
+11 -4
View File
@@ -40,17 +40,24 @@
- 如果由机器 A 集中采集 agent 指标,机器 A 必须能访问 `http://机器B内网IP:9105/metrics`。
- 两台机器都必须能访问配置中的 HTTP API 和各自的 WS 地址。
- `8090` 是无鉴权的压测协调端口,只允许压测内网访问,不要暴露到公网。
- 两台机器必须使用 NTP/chrony 同步时间;端到端延迟依赖两边时钟一致。
- 两台机器必须使用 NTP/chrony 同步时间。报告会分别输出同机时钟延迟和跨机器单向延迟;跨机器出现负延迟样本时,后者仅用于诊断,不参与门禁。
- agent 默认每 20 秒发送 WebSocket Ping,覆盖等待另一台机器、发压、drain 和历史核验阶段;`ws_ping_interval` 必须小于公网入口的空闲连接超时。
- 正式开始前 agent 会持续检查实际存活连接;连接断开后自动重新登录并订阅 P2P、群 Topic。初次连接不齐或等待另一台机器期间掉线不会退出。
- coordinator 使用两阶段屏障:两边实时连接数都达到配置值后进入验证倒计时;倒计时内任一方掉线就撤销屏障。两边再次确认满连接后才提交最终开始时间。
- 压测机如保持 OpenVPN 等全局 TUN,仅在另一套代理中设置 `DIRECT` 无法绕过它。起压前必须确认 `http_base` 和 WS 域名的每个当前 IP 都走物理网卡;需保留 VPN 访问内网时,可为这些 IP 配置经局域网关的 `/32` 主机路由。VPN 重连、DNS 变更或机器重启后需重新核验。
## 统计口径
- 发送成功以 `{ctrl code=2xx, params.seq>0}` 为准,形成两台机器共享的 ACK 台账。
- 实时到达只统计同时匹配本轮 `run_id` 和 `x-im-client-mid` 的 `{data}`。
- 群消息设置 `noecho=false`,每条服务端接受的群消息期望 2000 个连接各收到一次。
- 实时 drain 结束后通过 `{get what=data}` 按 ACK 的 Topic/Seq 回查历史,区分跨 Pod 实时漏投与数据库持久化缺失。
- 实时 drain 结束后,两机先汇总 ACK 台账和“已实时观测但未收到 ACK”的消息台账;等待 `history_settle`(默认 5 分钟)让 Kafka 自然排空,再通过 `{get what=data}` 按 Topic/Seq 回查两类消息。
- 每轮建立 WS 后、正式计时前,以 32 个 worker 对本机 1000 个账号做好友关系、P2P Topic 和发布权限预检;任一异常会终止本轮。
- 历史回查默认使用 48 个 worker、总超时 3 分钟,并对缺失项再并发重试两轮。
- publish 错误完整分类为 `403`、`502`、`503`、`ctrl_timeout`、`socket_error` 和 `other`,不受文本错误样本上限影响。
- 历史回查默认每机最多使用 12 个 worker、每页 10 条、同一 Topic 两页之间至少间隔 500ms,并在每页请求后等待目标消息全部返回(最长 3 秒)再继续。同一 Topic 严格串行,不同 Topic 才并行;因此群聊只会有 1 个活跃回查 worker,单聊可并行核验不同 Topic。总超时默认 3 分钟,并在超时内持续重试缺失区间。该限速用于避免正确性核验本身制造 Topic/Proxy 洪峰;历史读取吞吐应使用独立场景测试。
- agent 完成后默认在 10 秒内均匀关闭本机 WebSocket,避免 1000 个群订阅同时离线冲击 Topic 队列。两机退出时间可能相差数秒,协调器完成以两份 summary 都已提交为准。
- publish 错误完整分类为 `403`、`502`、`503`、`ctrl_timeout`、`socket_error` 和 `other`。`publish_rejected` 只统计明确拒绝,超时和 socket 错误单独计入 `publish_outcome_unknown`。
- `delivery_local_p95_ms` 使用发送方本机收到 echo 的同机时钟样本,是投递延迟门禁口径;兼容字段 `delivery_p95_ms` 与它保持一致。
- `delivery_cross_machine_p95_ms` 是跨机器单向观测值,只有两机时钟同步达标时才有意义;`cross_machine_negative_latency_samples > 0` 明确表示存在时钟偏差。
- `manifest.json` 含登录 token,文件权限为 `0600`,不得提交到 Git 或放入结果包。
## 两台机器初始化
+327 -51
View File
@@ -24,10 +24,10 @@ type agentRun struct {
runID string
scenario string
clients map[int]*WSClient
clientsMu sync.RWMutex
recorder *deliveryRecorder
ack *DurationHistogram
attempted atomic.Int64
rejected atomic.Int64
ready atomic.Int64
publish403 atomic.Int64
publish502 atomic.Int64
@@ -132,24 +132,59 @@ func runAgent(ctx context.Context, cfg *Config, manifest *Manifest, machine, coo
return err
}
defer stopMetrics()
if err = run.connect(ctx, accounts, manifest.GroupTopic, apiKey, password); err != nil {
run.close()
return err
for {
err = run.connect(ctx, accounts, manifest.GroupTopic, apiKey, password)
if err == nil {
break
}
if ctx.Err() != nil {
run.close()
return ctx.Err()
}
log.Printf("agent=%s initial connection set incomplete; retrying: %v", machine, err)
if err = waitContext(ctx, 2*time.Second); err != nil {
run.close()
return err
}
}
defer run.close()
start, err := coord.ready(ctx, AgentReady{Machine: machine, ReadyConnections: len(run.clients)})
maintenanceCtx, stopMaintenance := context.WithCancel(ctx)
maintenanceDone := make(chan struct{})
go func() {
defer close(maintenanceDone)
run.maintainConnections(maintenanceCtx, accounts, manifest.GroupTopic, apiKey, password)
}()
start, err := coord.ready(ctx, func() AgentReady {
return AgentReady{
Machine: machine, ReadyConnections: run.aliveConnections(), ExpectedConnections: len(accounts),
}
})
if err != nil {
stopMaintenance()
<-maintenanceDone
return err
}
if !runIDPattern.MatchString(start.RunID) || !validScenario(start.Scenario) {
stopMaintenance()
<-maintenanceDone
return fmt.Errorf("invalid coordinator start: %+v", start)
}
run.runID = start.RunID
run.scenario = start.Scenario
run.recorder.setRunID(start.RunID)
if err = waitUntil(ctx, start.StartAt); err != nil {
stopMaintenance()
<-maintenanceDone
return err
}
stopMaintenance()
<-maintenanceDone
if alive := run.aliveConnections(); alive != len(accounts) {
return fmt.Errorf("connections alive at scheduled start=%d expected=%d", alive, len(accounts))
}
// Connection churn while waiting for the other machine is outside the load
// measurement window. From this point onward every disconnect is a failure.
run.recorder.resetDisconnects()
startedAt := time.Now().UTC()
log.Printf("agent=%s scenario=%s started", machine, start.Scenario)
if err = run.execute(ctx, start.StartAt, manifest.GroupTopic); err != nil {
@@ -171,11 +206,21 @@ func runAgent(ctx context.Context, cfg *Config, manifest *Manifest, machine, coo
if err = waitContext(ctx, drain); err != nil {
return err
}
observedLocal := run.recorder.unackedObservedMessages(ledger, start.Scenario)
if err = coord.submitObserved(ctx, machine, observedLocal); err != nil {
return err
}
observedLedger, err := coord.observed(ctx)
if err != nil {
return err
}
verificationLedger := mergeMessageLedgers(ledger, observedLedger)
base := AgentSummary{
RunID: start.RunID, Scenario: start.Scenario, Machine: machine, StartedAt: startedAt,
LoadEndedAt: loadEndedAt, FinishedAt: time.Now().UTC(), ExpectedConnections: cfg.UsersPerMachine,
ReadyConnections: len(run.clients),
PublishAttempted: run.attempted.Load(), PublishAccepted: int64(len(local)), PublishRejected: run.rejected.Load(),
ReadyConnections: run.clientCount(),
PublishAttempted: run.attempted.Load(), PublishAccepted: int64(len(local)),
PublishRejected: run.explicitRejected(), PublishOutcomeUnknown: run.outcomeUnknown(),
PublishErrors: run.publishErrorCounts(),
AckLatency: run.ack.Snapshot(), AcceptedLocal: local,
}
@@ -183,11 +228,26 @@ func runAgent(ctx context.Context, cfg *Config, manifest *Manifest, machine, coo
base.Errors = append(base.Errors, run.errors...)
run.mu.Unlock()
summary := run.recorder.summarize(base, ledger)
checked, found, persistenceMissing := run.reconcileHistory(ctx, ledger)
summary.HistoryChecked = int64(checked)
summary.HistoryFound = int64(found)
historySettle, _ := configDuration(cfg.HistorySettle)
log.Printf("agent=%s waiting %s before history verification", machine, historySettle)
if err = waitContext(ctx, historySettle); err != nil {
return err
}
history := run.reconcileHistory(ctx, verificationLedger, ledger)
if err = coord.submitHistory(ctx, machine, history.foundIDs); err != nil {
return err
}
storedMessageIDs, err := coord.history(ctx)
if err != nil {
return err
}
summary.HistoryChecked = int64(history.checked)
summary.HistoryFound = int64(history.found)
summary.HistoryUnackedChecked = int64(history.unackedChecked)
summary.HistoryUnackedFound = int64(history.unackedFound)
persistenceMissing := history.missing
summary.PersistenceLost = int64(len(persistenceMissing))
summary.RealtimeLostStored = run.recorder.countRealtimeLostStored(ledger)
summary.RealtimeLostStored = run.recorder.countRealtimeLostStored(ledger, storedMessageIDs)
if len(persistenceMissing) > 10000 {
summary.PersistenceMissing = append([]string(nil), persistenceMissing[:10000]...)
summary.Errors = append(summary.Errors, "persistence missing details truncated at 10000")
@@ -208,15 +268,29 @@ func runAgent(ctx context.Context, cfg *Config, manifest *Manifest, machine, coo
}
type historyQuery struct {
client *WSClient
topic string
since int64
before int64
limit int
client *WSClient
topic string
since int64
before int64
limit int
expected []string
}
func (r *agentRun) reconcileHistory(ctx context.Context, ledger []AcceptedMessage) (int, int, []string) {
type historyResult struct {
checked int
found int
unackedChecked int
unackedFound int
missing []string
foundIDs []string
}
func (r *agentRun) reconcileHistory(ctx context.Context, ledger, ackLedger []AcceptedMessage) historyResult {
expected := make(map[string]AcceptedMessage)
acked := make(map[string]bool, len(ackLedger))
for _, message := range ackLedger {
acked[message.ClientMid] = true
}
byTopic := make(map[string][]AcceptedMessage)
for _, message := range ledger {
if message.RunID != r.runID {
@@ -231,27 +305,27 @@ func (r *agentRun) reconcileHistory(ctx context.Context, ledger []AcceptedMessag
}
}
if len(expected) == 0 {
return 0, 0, nil
return historyResult{}
}
r.recorder.beginHistory()
queries := make([]historyQuery, 0, len(byTopic))
for topic, messages := range byTopic {
sort.Slice(messages, func(i, j int) bool { return messages[i].Seq < messages[j].Seq })
client := r.clients[messages[0].PeerIndex]
client := r.client(messages[0].PeerIndex)
if messages[0].Kind == "group" {
client = r.clients[1]
client = r.client(1)
}
if client == nil {
r.addError(fmt.Sprintf("history topic=%s has no verifier client", topicHash(topic)))
continue
}
for start := 0; start < len(messages); start += 200 {
end := start + 200
pageSize := r.historyPageSize()
for start := 0; start < len(messages); start += pageSize {
end := start + pageSize
if end > len(messages) {
end = len(messages)
}
queries = append(queries, historyQuery{client: client, topic: topic,
since: messages[start].Seq, before: messages[end-1].Seq + 1, limit: 256})
queries = append(queries, newHistoryQuery(client, topic, messages[start:end], pageSize))
}
}
historyTimeout, _ := configDuration(r.cfg.HistoryTimeout)
@@ -274,7 +348,22 @@ func (r *agentRun) reconcileHistory(ctx context.Context, ledger []AcceptedMessag
missingIDs = append(missingIDs, message.ClientMid)
}
sort.Strings(missingIDs)
return len(expected), len(expected) - len(missing), missingIDs
result := historyResult{checked: len(expected), found: len(expected) - len(missing), missing: missingIDs,
foundIDs: make([]string, 0, len(expected)-len(missing))}
for mid := range expected {
if r.recorder.historyFound(mid) {
result.foundIDs = append(result.foundIDs, mid)
}
if acked[mid] {
continue
}
result.unackedChecked++
if r.recorder.historyFound(mid) {
result.unackedFound++
}
}
sort.Strings(result.foundIDs)
return result
}
func (r *agentRun) historyQueriesForMessages(messages []AcceptedMessage) []historyQuery {
@@ -285,49 +374,75 @@ func (r *agentRun) historyQueriesForMessages(messages []AcceptedMessage) []histo
queries := make([]historyQuery, 0, len(byTopic))
for topic, topicMessages := range byTopic {
sort.Slice(topicMessages, func(i, j int) bool { return topicMessages[i].Seq < topicMessages[j].Seq })
client := r.clients[topicMessages[0].PeerIndex]
client := r.client(topicMessages[0].PeerIndex)
if topicMessages[0].Kind == "group" {
client = r.clients[1]
client = r.client(1)
}
if client == nil {
continue
}
for start := 0; start < len(topicMessages); start += 200 {
end := start + 200
pageSize := r.historyPageSize()
for start := 0; start < len(topicMessages); start += pageSize {
end := start + pageSize
if end > len(topicMessages) {
end = len(topicMessages)
}
queries = append(queries, historyQuery{client: client, topic: topic,
since: topicMessages[start].Seq, before: topicMessages[end-1].Seq + 1, limit: 256})
queries = append(queries, newHistoryQuery(client, topic, topicMessages[start:end], pageSize))
}
}
return queries
}
func (r *agentRun) issueHistoryQueries(ctx context.Context, queries []historyQuery) {
byTopic := make(map[string][]historyQuery)
for _, query := range queries {
byTopic[query.topic] = append(byTopic[query.topic], query)
}
workers := r.cfg.HistoryWorkers
if workers > len(queries) {
workers = len(queries)
if workers > len(byTopic) {
workers = len(byTopic)
}
if workers == 0 {
return
}
jobs := make(chan historyQuery)
jobs := make(chan []historyQuery)
interval := 500 * time.Millisecond
if r.cfg != nil && r.cfg.HistoryInterval != "" {
interval = mustDuration(r.cfg.HistoryInterval)
}
pageWait := 3 * time.Second
if r.cfg != nil && r.cfg.HistoryPageWait != "" {
pageWait = mustDuration(r.cfg.HistoryPageWait)
}
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for query := range jobs {
if err := query.client.GetData(ctx, query.topic, query.since, query.before, query.limit); err != nil && ctx.Err() == nil {
r.addError(fmt.Sprintf("history get topic=%s range=%d-%d: %v", topicHash(query.topic), query.since, query.before, err))
for topicQueries := range jobs {
for index, query := range topicQueries {
if index > 0 {
if err := waitContext(ctx, interval); err != nil {
return
}
}
if err := query.client.GetData(ctx, query.topic, query.since, query.before, query.limit); err != nil && ctx.Err() == nil {
r.addError(fmt.Sprintf("history get topic=%s range=%d-%d: %v", topicHash(query.topic), query.since, query.before, err))
continue
}
r.waitHistoryPage(ctx, query.expected, pageWait)
}
}
}()
}
for _, query := range queries {
topics := make([]string, 0, len(byTopic))
for topic := range byTopic {
topics = append(topics, topic)
}
sort.Strings(topics)
for _, topic := range topics {
select {
case jobs <- query:
case jobs <- byTopic[topic]:
case <-ctx.Done():
close(jobs)
wg.Wait()
@@ -338,6 +453,41 @@ func (r *agentRun) issueHistoryQueries(ctx context.Context, queries []historyQue
wg.Wait()
}
func newHistoryQuery(client *WSClient, topic string, messages []AcceptedMessage, limit int) historyQuery {
expected := make([]string, 0, len(messages))
for _, message := range messages {
expected = append(expected, message.ClientMid)
}
return historyQuery{client: client, topic: topic, since: messages[0].Seq,
before: messages[len(messages)-1].Seq + 1, limit: limit, expected: expected}
}
func (r *agentRun) waitHistoryPage(ctx context.Context, expected []string, timeout time.Duration) {
deadline := time.NewTimer(timeout)
defer deadline.Stop()
ticker := time.NewTicker(25 * time.Millisecond)
defer ticker.Stop()
for {
if r.recorder.historyFoundAll(expected) {
return
}
select {
case <-ctx.Done():
return
case <-deadline.C:
return
case <-ticker.C:
}
}
}
func (r *agentRun) historyPageSize() int {
if r.cfg != nil && r.cfg.HistoryPageSize > 0 {
return r.cfg.HistoryPageSize
}
return 10
}
func missingHistory(recorder *deliveryRecorder, expected map[string]AcceptedMessage) []AcceptedMessage {
missing := make([]AcceptedMessage, 0)
for mid, message := range expected {
@@ -348,6 +498,33 @@ func missingHistory(recorder *deliveryRecorder, expected map[string]AcceptedMess
return missing
}
func mergeMessageLedgers(accepted, observed []AcceptedMessage) []AcceptedMessage {
byID := make(map[string]AcceptedMessage, len(accepted)+len(observed))
for _, message := range observed {
if message.ClientMid == "" {
continue
}
current, exists := byID[message.ClientMid]
currentIsPeer := current.PeerMachine != "" && current.PeerMachine != current.SenderMachine
candidateIsPeer := message.PeerMachine != "" && message.PeerMachine != message.SenderMachine
if !exists || (!currentIsPeer && candidateIsPeer) {
byID[message.ClientMid] = message
}
}
// ACK-derived records carry authoritative sender and timing fields.
for _, message := range accepted {
if message.ClientMid != "" {
byID[message.ClientMid] = message
}
}
merged := make([]AcceptedMessage, 0, len(byID))
for _, message := range byID {
merged = append(merged, message)
}
sort.Slice(merged, func(i, j int) bool { return merged[i].ClientMid < merged[j].ClientMid })
return merged
}
func (r *agentRun) addError(message string) {
r.mu.Lock()
defer r.mu.Unlock()
@@ -364,9 +541,14 @@ func (r *agentRun) connect(ctx context.Context, accounts []AccountRecord, groupT
interval := ramp / time.Duration(len(accounts))
var wg sync.WaitGroup
errCh := make(chan error, len(accounts))
var mu sync.Mutex
for _, account := range accounts {
account := account
if client := r.client(account.Index); client != nil {
if client.alive() {
continue
}
client.Close()
}
wg.Add(1)
go func() {
defer wg.Done()
@@ -375,9 +557,7 @@ func (r *agentRun) connect(ctx context.Context, accounts []AccountRecord, groupT
errCh <- connectErr
return
}
mu.Lock()
r.clients[account.Index] = client
mu.Unlock()
r.setClient(account.Index, client)
r.ready.Add(1)
}()
if interval > 0 {
@@ -394,12 +574,55 @@ func (r *agentRun) connect(ctx context.Context, accounts []AccountRecord, groupT
failures = append(failures, connectErr.Error())
}
}
if len(r.clients) != len(accounts) {
return fmt.Errorf("ready connections=%d expected=%d failures=%v", len(r.clients), len(accounts), failures)
if r.aliveConnections() != len(accounts) {
return fmt.Errorf("ready connections=%d expected=%d failures=%v", r.aliveConnections(), len(accounts), failures)
}
return nil
}
func (r *agentRun) maintainConnections(ctx context.Context, accounts []AccountRecord, groupTopic, apiKey, password string) {
byIndex := make(map[int]AccountRecord, len(accounts))
for _, account := range accounts {
byIndex[account.Index] = account
}
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
if ctx.Err() != nil {
return
}
var wg sync.WaitGroup
for index, account := range byIndex {
current := r.client(index)
if current != nil && current.alive() {
continue
}
wg.Add(1)
go func(index int, account AccountRecord, previous *WSClient) {
defer wg.Done()
if previous != nil {
previous.Close()
}
client, err := r.connectAccountWithRetry(ctx, account, groupTopic, apiKey, password)
if err != nil {
if ctx.Err() == nil {
log.Printf("account=%s pre-start reconnect failed: %v", account.Username, err)
}
return
}
r.setClient(index, client)
log.Printf("account=%s pre-start connection restored", account.Username)
}(index, account, current)
}
wg.Wait()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (r *agentRun) connectAccountWithRetry(ctx context.Context, account AccountRecord, groupTopic, apiKey, password string) (*WSClient, error) {
var lastErr error
for attempt := 1; attempt <= 6; attempt++ {
@@ -459,7 +682,7 @@ func (r *agentRun) execute(ctx context.Context, start time.Time, groupTopic stri
func (r *agentRun) scheduleP2P(ctx context.Context, start time.Time, users, messages int, interval time.Duration) error {
var wg sync.WaitGroup
for index := 1; index <= users; index++ {
client := r.clients[index]
client := r.client(index)
if client == nil {
return fmt.Errorf("missing client %d", index)
}
@@ -491,7 +714,7 @@ func (r *agentRun) scheduleP2P(ctx context.Context, start time.Time, users, mess
}
func (r *agentRun) scheduleGroup(ctx context.Context, start time.Time, topic string, first, last, rounds int) error {
if first < 1 || last > len(r.clients) || first > last {
if first < 1 || last > r.clientCount() || first > last {
return fmt.Errorf("invalid group publisher range %d-%d", first, last)
}
count := last - first + 1
@@ -502,7 +725,7 @@ func (r *agentRun) scheduleGroup(ctx context.Context, start time.Time, topic str
}
var wg sync.WaitGroup
for index := first; index <= last; index++ {
client := r.clients[index]
client := r.client(index)
position := index - first
wg.Add(1)
go func(index, position int, client *WSClient) {
@@ -528,7 +751,6 @@ func (r *agentRun) publish(ctx context.Context, client *WSClient, topic, mid str
r.attempted.Add(1)
accepted, err := client.Publish(ctx, topic, mid, r.runID, r.scenario, messageIndex)
if err != nil {
r.rejected.Add(1)
r.classifyPublishError(err)
r.mu.Lock()
if len(r.errors) < 1000 {
@@ -553,7 +775,7 @@ func (r *agentRun) classifyPublishError(err error) {
case strings.Contains(err.Error(), "ctrl timeout") || errors.Is(err, context.DeadlineExceeded):
r.publishTimeout.Add(1)
case strings.Contains(err.Error(), "websocket") || strings.Contains(err.Error(), "broken pipe") ||
strings.Contains(err.Error(), "connection reset"):
strings.Contains(err.Error(), "connection reset") || strings.Contains(err.Error(), "closed network connection"):
r.publishSocket.Add(1)
default:
r.publishOther.Add(1)
@@ -567,6 +789,14 @@ func (r *agentRun) publishErrorCounts() map[string]int64 {
}
}
func (r *agentRun) explicitRejected() int64 {
return r.publish403.Load() + r.publish502.Load() + r.publish503.Load() + r.publishOther.Load()
}
func (r *agentRun) outcomeUnknown() int64 {
return r.publishTimeout.Load() + r.publishSocket.Load()
}
func (r *agentRun) addAccepted(message AcceptedMessage) {
r.mu.Lock()
r.accepted = append(r.accepted, message)
@@ -581,6 +811,44 @@ func (r *agentRun) acceptedCopy() []AcceptedMessage {
return out
}
func (r *agentRun) aliveConnections() int {
alive := 0
for _, client := range r.clientsSnapshot() {
if client.alive() {
alive++
}
}
return alive
}
func (r *agentRun) client(index int) *WSClient {
r.clientsMu.RLock()
defer r.clientsMu.RUnlock()
return r.clients[index]
}
func (r *agentRun) setClient(index int, client *WSClient) {
r.clientsMu.Lock()
r.clients[index] = client
r.clientsMu.Unlock()
}
func (r *agentRun) clientCount() int {
r.clientsMu.RLock()
defer r.clientsMu.RUnlock()
return len(r.clients)
}
func (r *agentRun) clientsSnapshot() []*WSClient {
r.clientsMu.RLock()
defer r.clientsMu.RUnlock()
clients := make([]*WSClient, 0, len(r.clients))
for _, client := range r.clients {
clients = append(clients, client)
}
return clients
}
func (r *agentRun) drainDuration() (time.Duration, error) {
if r.scenario == "p2p" {
return configDuration(r.cfg.DrainP2P)
@@ -589,8 +857,16 @@ func (r *agentRun) drainDuration() (time.Duration, error) {
}
func (r *agentRun) close() {
for _, client := range r.clients {
clients := r.clientsSnapshot()
var interval time.Duration
if r.cfg != nil && r.cfg.CloseRamp != "" && len(clients) > 1 {
interval = mustDuration(r.cfg.CloseRamp) / time.Duration(len(clients)-1)
}
for index, client := range clients {
client.Close()
if interval > 0 && index < len(clients)-1 {
time.Sleep(interval)
}
}
}
+29 -3
View File
@@ -1,11 +1,14 @@
package main
import "testing"
import (
"errors"
"testing"
)
func TestHistoryQueriesForMessagesGroupsRangesByTopic(t *testing.T) {
p2pClient := &WSClient{}
groupClient := &WSClient{}
run := &agentRun{clients: map[int]*WSClient{1: groupClient, 7: p2pClient}}
run := &agentRun{cfg: &Config{HistoryPageSize: 200}, clients: map[int]*WSClient{1: groupClient, 7: p2pClient}}
messages := make([]AcceptedMessage, 0, 207)
for seq := int64(205); seq >= 1; seq-- {
messages = append(messages, AcceptedMessage{Kind: "p2p", Topic: "p2p-a", PeerIndex: 7, Seq: seq})
@@ -25,7 +28,7 @@ func TestHistoryQueriesForMessagesGroupsRangesByTopic(t *testing.T) {
switch query.topic {
case "p2p-a":
p2pQueries++
if query.client != p2pClient || query.limit != 256 {
if query.client != p2pClient || query.limit != 200 || len(query.expected) == 0 {
t.Fatalf("unexpected p2p query: %#v", query)
}
case "grp-a":
@@ -39,3 +42,26 @@ func TestHistoryQueriesForMessagesGroupsRangesByTopic(t *testing.T) {
t.Fatalf("p2p queries=%d group queries=%d", p2pQueries, groupQueries)
}
}
func TestClassifyPublishErrorTreatsClosedConnectionAsUnknownSocketError(t *testing.T) {
run := &agentRun{}
run.classifyPublishError(errors.New("write tcp: use of closed network connection"))
if run.publishSocket.Load() != 1 || run.publishOther.Load() != 0 {
t.Fatalf("socket=%d other=%d", run.publishSocket.Load(), run.publishOther.Load())
}
}
func TestMergeMessageLedgersPrefersPeerObservationAndAck(t *testing.T) {
senderEcho := AcceptedMessage{ClientMid: "mid-1", SenderMachine: "a", PeerMachine: "a", Seq: 10}
peerDelivery := AcceptedMessage{ClientMid: "mid-1", SenderMachine: "a", PeerMachine: "b", PeerIndex: 7, Seq: 10}
merged := mergeMessageLedgers(nil, []AcceptedMessage{senderEcho, peerDelivery})
if len(merged) != 1 || merged[0].PeerMachine != "b" || merged[0].PeerIndex != 7 {
t.Fatalf("peer observation was not preferred: %+v", merged)
}
ack := AcceptedMessage{ClientMid: "mid-1", SenderMachine: "a", PeerMachine: "b", PeerIndex: 7, Seq: 10,
Scenario: "p2p"}
merged = mergeMessageLedgers([]AcceptedMessage{ack}, []AcceptedMessage{peerDelivery})
if len(merged) != 1 || merged[0].Scenario != "p2p" {
t.Fatalf("ACK ledger was not authoritative: %+v", merged)
}
}
+6 -3
View File
@@ -26,7 +26,7 @@ func startAgentMetrics(address string, run *agentRun) (func(), error) {
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{Name: "im_load_agent_ready_connections", Help: "Current ready WebSocket connections.", ConstLabels: labels}, func() float64 {
return float64(run.ready.Load())
return float64(run.aliveConnections())
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_attempted_total", Help: "Publish commands attempted.", ConstLabels: labels}, func() float64 {
return float64(run.attempted.Load())
@@ -36,8 +36,11 @@ func startAgentMetrics(address string, run *agentRun) (func(), error) {
defer run.mu.Unlock()
return float64(len(run.accepted))
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_rejected_total", Help: "Publish commands rejected or timed out.", ConstLabels: labels}, func() float64 {
return float64(run.rejected.Load())
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_rejected_total", Help: "Publish commands explicitly rejected by the server.", ConstLabels: labels}, func() float64 {
return float64(run.explicitRejected())
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_outcome_unknown_total", Help: "Publish commands with ACK timeout or socket failure and therefore unknown outcome.", ConstLabels: labels}, func() float64 {
return float64(run.outcomeUnknown())
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_403_total", Help: "Publish commands rejected with 403.", ConstLabels: labels}, func() float64 { return float64(run.publish403.Load()) }),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_502_total", Help: "Publish commands rejected with 502.", ConstLabels: labels}, func() float64 { return float64(run.publish502.Load()) }),
@@ -0,0 +1,88 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/websocket"
)
func TestMaintainConnectionsRestoresClosedClient(t *testing.T) {
var connections atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
connections.Add(1)
defer conn.Close()
for {
var packet map[string]json.RawMessage
if err = conn.ReadJSON(&packet); err != nil {
return
}
for _, raw := range packet {
var body struct {
ID string `json:"id"`
}
if json.Unmarshal(raw, &body) != nil || body.ID == "" {
continue
}
response := map[string]any{"ctrl": map[string]any{
"id": body.ID, "code": 200, "text": "ok",
"params": map[string]any{"user": "usrReconnectTest"},
}}
if err = conn.WriteJSON(response); err != nil {
return
}
}
}
}))
defer server.Close()
account := AccountRecord{
Machine: "a", Index: 1, Username: "lta0001", Token: "token",
P2PTopic: "p2pReconnectTest", Endpoint: "ws" + strings.TrimPrefix(server.URL, "http"),
}
cfg := &Config{Tenant: "allrich", CommandTimeout: "1s", WSPingInterval: "1h"}
run := &agentRun{
cfg: cfg, machine: "a", clients: make(map[int]*WSClient),
recorder: newDeliveryRecorder("pending", "a", 1), ack: NewDurationHistogram(),
}
initial, err := run.connectAccountWithRetry(context.Background(), account, "grpReconnectTest", "api-key", "")
if err != nil {
t.Fatal(err)
}
run.setClient(account.Index, initial)
initial.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
done := make(chan struct{})
go func() {
run.maintainConnections(ctx, []AccountRecord{account}, "grpReconnectTest", "api-key", "")
close(done)
}()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if replacement := run.client(account.Index); replacement != initial && replacement.alive() {
cancel()
<-done
run.close()
if got := connections.Load(); got < 2 {
t.Fatalf("websocket connections = %d, want at least 2", got)
}
return
}
time.Sleep(10 * time.Millisecond)
}
cancel()
<-done
run.close()
t.Fatal("closed client was not restored before deadline")
}
+7 -1
View File
@@ -14,10 +14,16 @@
"prepare_workers": 20,
"connect_ramp": "20s",
"command_timeout": "20s",
"ws_ping_interval": "20s",
"preflight_workers": 32,
"skip_p2p_preflight": false,
"history_workers": 48,
"history_workers": 12,
"history_page_size": 10,
"history_query_interval": "500ms",
"history_page_wait": "3s",
"history_settle": "5m",
"history_timeout": "3m",
"close_ramp": "10s",
"p2p_messages_per_user": 10,
"p2p_interval": "1s",
"drain_p2p": "30s",
+215 -27
View File
@@ -16,23 +16,30 @@ import (
)
type StartInfo struct {
RunID string `json:"run_id"`
Scenario string `json:"scenario"`
StartAt time.Time `json:"start_at"`
RunID string `json:"run_id"`
Scenario string `json:"scenario"`
StartAt time.Time `json:"start_at"`
Committed bool `json:"committed"`
}
type AgentReady struct {
Machine string `json:"machine"`
ReadyConnections int `json:"ready_connections"`
Machine string `json:"machine"`
ReadyConnections int `json:"ready_connections"`
ExpectedConnections int `json:"expected_connections"`
Armed bool `json:"armed"`
}
type coordinatorState struct {
mu sync.Mutex
start StartInfo
startDelay time.Duration
commitDelay time.Duration
expectedAgents int
ready map[string]AgentReady
armed map[string]bool
ledgers map[string][]AcceptedMessage
observed map[string][]AcceptedMessage
history map[string][]string
summaries map[string]AgentSummary
complete chan struct{}
completeOnce sync.Once
@@ -49,14 +56,18 @@ func runCoordinator(ctx context.Context, listen, runID, scenario, output string,
expectedAgents = 2
}
state := &coordinatorState{
start: StartInfo{RunID: runID, Scenario: scenario}, startDelay: startDelay, expectedAgents: expectedAgents,
ready: make(map[string]AgentReady), ledgers: make(map[string][]AcceptedMessage),
summaries: make(map[string]AgentSummary), complete: make(chan struct{}),
start: StartInfo{RunID: runID, Scenario: scenario}, startDelay: startDelay, commitDelay: 5 * time.Second,
expectedAgents: expectedAgents,
ready: make(map[string]AgentReady), ledgers: make(map[string][]AcceptedMessage),
observed: make(map[string][]AcceptedMessage), history: make(map[string][]string),
armed: make(map[string]bool), summaries: make(map[string]AgentSummary), complete: make(chan struct{}),
}
mux := http.NewServeMux()
mux.HandleFunc("/v1/ready", state.handleReady)
mux.HandleFunc("/v1/start", state.handleStart)
mux.HandleFunc("/v1/ledger", state.handleLedger)
mux.HandleFunc("/v1/observed", state.handleObserved)
mux.HandleFunc("/v1/history", state.handleHistory)
mux.HandleFunc("/v1/summary", state.handleSummary)
mux.HandleFunc("/v1/status", state.handleStatus)
server := &http.Server{Addr: listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
@@ -85,23 +96,138 @@ func runCoordinator(ctx context.Context, listen, runID, scenario, output string,
return nil
}
func (s *coordinatorState) handleHistory(w http.ResponseWriter, r *http.Request) {
machine := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("machine")))
switch r.Method {
case http.MethodPost:
if machine != "a" && machine != "b" {
http.Error(w, "invalid machine", http.StatusBadRequest)
return
}
var messageIDs []string
if !decodeLimitedJSON(w, r, &messageIDs) {
return
}
s.mu.Lock()
if s.history == nil {
s.history = make(map[string][]string)
}
s.history[machine] = messageIDs
count := len(s.history)
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]int{"submitted_agents": count})
case http.MethodGet:
s.mu.Lock()
defer s.mu.Unlock()
if len(s.history) < s.expectedAgents {
writeJSON(w, http.StatusAccepted, map[string]int{"submitted_agents": len(s.history), "expected_agents": s.expectedAgents})
return
}
unique := make(map[string]bool)
for _, messageIDs := range s.history {
for _, mid := range messageIDs {
if mid != "" {
unique[mid] = true
}
}
}
merged := make([]string, 0, len(unique))
for mid := range unique {
merged = append(merged, mid)
}
sort.Strings(merged)
writeJSON(w, http.StatusOK, merged)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *coordinatorState) handleObserved(w http.ResponseWriter, r *http.Request) {
machine := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("machine")))
switch r.Method {
case http.MethodPost:
if machine != "a" && machine != "b" {
http.Error(w, "invalid machine", http.StatusBadRequest)
return
}
var messages []AcceptedMessage
if !decodeLimitedJSON(w, r, &messages) {
return
}
s.mu.Lock()
if s.observed == nil {
s.observed = make(map[string][]AcceptedMessage)
}
s.observed[machine] = messages
count := len(s.observed)
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]int{"submitted_agents": count})
case http.MethodGet:
s.mu.Lock()
defer s.mu.Unlock()
if len(s.observed) < s.expectedAgents {
writeJSON(w, http.StatusAccepted, map[string]int{"submitted_agents": len(s.observed), "expected_agents": s.expectedAgents})
return
}
var merged []AcceptedMessage
for _, messages := range s.observed {
merged = append(merged, messages...)
}
merged = mergeMessageLedgers(nil, merged)
writeJSON(w, http.StatusOK, merged)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *coordinatorState) handleReady(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var ready AgentReady
if !decodeLimitedJSON(w, r, &ready) || (ready.Machine != "a" && ready.Machine != "b") || ready.ReadyConnections <= 0 {
if !decodeLimitedJSON(w, r, &ready) || (ready.Machine != "a" && ready.Machine != "b") ||
ready.ExpectedConnections <= 0 || ready.ReadyConnections < 0 || ready.ReadyConnections > ready.ExpectedConnections {
if ready.Machine != "a" && ready.Machine != "b" {
http.Error(w, "invalid machine", http.StatusBadRequest)
} else {
http.Error(w, "invalid connection counts", http.StatusBadRequest)
}
return
}
s.mu.Lock()
s.ready[ready.Machine] = ready
if len(s.ready) == s.expectedAgents && s.start.StartAt.IsZero() {
s.start.StartAt = time.Now().UTC().Add(s.startDelay)
log.Printf("all agents ready; start_at=%s", s.start.StartAt.Format(time.RFC3339Nano))
now := time.Now().UTC()
allStable := len(s.ready) == s.expectedAgents
for _, agent := range s.ready {
if agent.ReadyConnections != agent.ExpectedConnections {
allStable = false
break
}
}
if !allStable && (!s.start.Committed || now.Before(s.start.StartAt)) {
log.Printf("agent readiness dropped before start; resetting start barrier")
s.start.StartAt = time.Time{}
s.start.Committed = false
clear(s.armed)
}
if allStable && s.start.StartAt.IsZero() {
s.start.StartAt = now.Add(s.startDelay)
s.start.Committed = false
clear(s.armed)
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) {
s.armed[ready.Machine] = true
}
if allStable && !s.start.Committed && len(s.armed) == s.expectedAgents {
delay := s.commitDelay
if delay <= 0 {
delay = 5 * time.Second
}
s.start.StartAt = now.Add(delay)
s.start.Committed = true
log.Printf("all agents validated; committed start_at=%s", s.start.StartAt.Format(time.RFC3339Nano))
}
start := s.start
s.mu.Unlock()
@@ -185,7 +311,9 @@ func (s *coordinatorState) handleStatus(w http.ResponseWriter, _ *http.Request)
s.mu.Lock()
defer s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{
"start": s.start, "ready": s.ready, "ledger_agents": len(s.ledgers), "summary_agents": len(s.summaries),
"start": s.start, "ready": s.ready, "armed": s.armed,
"ledger_agents": len(s.ledgers), "observed_agents": len(s.observed),
"history_agents": len(s.history), "summary_agents": len(s.summaries),
})
}
@@ -195,8 +323,13 @@ func (s *coordinatorState) writeOutput(path string) error {
Start StartInfo `json:"start"`
Ready map[string]AgentReady `json:"ready"`
Ledgers map[string][]AcceptedMessage `json:"ledgers"`
Observed map[string][]AcceptedMessage `json:"observed"`
History map[string][]string `json:"history"`
Summaries map[string]AgentSummary `json:"summaries"`
}{s.start, s.ready, s.ledgers, s.summaries}
}{
Start: s.start, Ready: s.ready, Ledgers: s.ledgers, Observed: s.observed,
History: s.history, Summaries: s.summaries,
}
s.mu.Unlock()
raw, err := json.MarshalIndent(data, "", " ")
if err != nil {
@@ -232,24 +365,37 @@ func newCoordinatorClient(base string) *coordinatorClient {
return &coordinatorClient{base: strings.TrimRight(base, "/"), client: &http.Client{Timeout: 35 * time.Second}}
}
func (c *coordinatorClient) ready(ctx context.Context, ready AgentReady) (StartInfo, error) {
func (c *coordinatorClient) ready(ctx context.Context, current func() AgentReady) (StartInfo, error) {
var start StartInfo
if _, err := c.request(ctx, http.MethodPost, "/v1/ready", ready, &start); err != nil {
return start, err
}
for start.StartAt.IsZero() {
armed := false
for {
state := current()
state.Armed = armed
if _, err := c.request(ctx, http.MethodPost, "/v1/ready", state, &start); err != nil {
return start, err
}
if start.StartAt.IsZero() {
armed = false
} else {
remaining := time.Until(start.StartAt)
if start.Committed && remaining <= 0 {
return start, nil
}
if !start.Committed && remaining <= 0 {
armed = true
continue
}
if remaining < time.Second {
if err := waitContext(ctx, remaining); err != nil {
return start, err
}
continue
}
}
if err := waitContext(ctx, time.Second); err != nil {
return start, err
}
status, err := c.request(ctx, http.MethodGet, "/v1/start", nil, &start)
if err != nil {
return start, err
}
if status != http.StatusOK {
start = StartInfo{}
}
}
return start, nil
}
func (c *coordinatorClient) submitLedger(ctx context.Context, machine string, messages []AcceptedMessage) error {
@@ -273,6 +419,48 @@ func (c *coordinatorClient) ledger(ctx context.Context) ([]AcceptedMessage, erro
}
}
func (c *coordinatorClient) submitObserved(ctx context.Context, machine string, messages []AcceptedMessage) error {
_, err := c.request(ctx, http.MethodPost, "/v1/observed?machine="+machine, messages, nil)
return err
}
func (c *coordinatorClient) observed(ctx context.Context) ([]AcceptedMessage, error) {
for {
var messages []AcceptedMessage
status, err := c.request(ctx, http.MethodGet, "/v1/observed", nil, &messages)
if err != nil {
return nil, err
}
if status == http.StatusOK {
return messages, nil
}
if err = waitContext(ctx, time.Second); err != nil {
return nil, err
}
}
}
func (c *coordinatorClient) submitHistory(ctx context.Context, machine string, messageIDs []string) error {
_, err := c.request(ctx, http.MethodPost, "/v1/history?machine="+machine, messageIDs, nil)
return err
}
func (c *coordinatorClient) history(ctx context.Context) ([]string, error) {
for {
var messageIDs []string
status, err := c.request(ctx, http.MethodGet, "/v1/history", nil, &messageIDs)
if err != nil {
return nil, err
}
if status == http.StatusOK {
return messageIDs, nil
}
if err = waitContext(ctx, time.Second); err != nil {
return nil, err
}
}
}
func (c *coordinatorClient) submitSummary(ctx context.Context, summary AgentSummary) error {
_, err := c.request(ctx, http.MethodPost, "/v1/summary", summary, nil)
return err
+70 -5
View File
@@ -1,18 +1,57 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestCoordinatorResetsBarrierWhenConnectionCountDrops(t *testing.T) {
state := &coordinatorState{
start: StartInfo{RunID: "run-reset", Scenario: "group"}, startDelay: time.Minute,
commitDelay: time.Second, expectedAgents: 2, ready: make(map[string]AgentReady),
armed: make(map[string]bool), ledgers: make(map[string][]AcceptedMessage),
summaries: make(map[string]AgentSummary), complete: make(chan struct{}),
}
postReady := func(ready AgentReady) StartInfo {
raw, err := json.Marshal(ready)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/ready", bytes.NewReader(raw))
w := httptest.NewRecorder()
state.handleReady(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ready status=%d body=%s", w.Code, w.Body.String())
}
var start StartInfo
if err = json.Unmarshal(w.Body.Bytes(), &start); err != nil {
t.Fatal(err)
}
return start
}
fullA := AgentReady{Machine: "a", ReadyConnections: 1000, ExpectedConnections: 1000}
fullB := AgentReady{Machine: "b", ReadyConnections: 1000, ExpectedConnections: 1000}
postReady(fullA)
if start := postReady(fullB); start.StartAt.IsZero() {
t.Fatal("full agents must create a validation barrier")
}
fullA.ReadyConnections = 999
if start := postReady(fullA); !start.StartAt.IsZero() || start.Committed {
t.Fatalf("connection drop must reset barrier: %+v", start)
}
}
func TestCoordinatorClientWaitsForBothAgentsAndMergesLedger(t *testing.T) {
state := &coordinatorState{
start: StartInfo{RunID: "run1", Scenario: "p2p"}, startDelay: time.Second, expectedAgents: 2,
start: StartInfo{RunID: "run1", Scenario: "p2p"}, startDelay: 50 * time.Millisecond,
commitDelay: 50 * time.Millisecond, expectedAgents: 2,
ready: make(map[string]AgentReady), ledgers: make(map[string][]AcceptedMessage),
summaries: make(map[string]AgentSummary), complete: make(chan struct{}),
armed: make(map[string]bool), summaries: make(map[string]AgentSummary), complete: make(chan struct{}),
}
server := httptest.NewServer(testCoordinatorMux(state))
defer server.Close()
@@ -20,12 +59,16 @@ func TestCoordinatorClientWaitsForBothAgentsAndMergesLedger(t *testing.T) {
aDone := make(chan StartInfo, 1)
go func() {
start, _ := client.ready(context.Background(), AgentReady{Machine: "a", ReadyConnections: 1000})
start, _ := client.ready(context.Background(), func() AgentReady {
return AgentReady{Machine: "a", ReadyConnections: 1000, ExpectedConnections: 1000}
})
aDone <- start
}()
time.Sleep(50 * time.Millisecond)
startB, err := client.ready(context.Background(), AgentReady{Machine: "b", ReadyConnections: 1000})
if err != nil || startB.StartAt.IsZero() {
startB, err := client.ready(context.Background(), func() AgentReady {
return AgentReady{Machine: "b", ReadyConnections: 1000, ExpectedConnections: 1000}
})
if err != nil || startB.StartAt.IsZero() || !startB.Committed {
t.Fatalf("machine B ready: start=%+v err=%v", startB, err)
}
select {
@@ -47,6 +90,26 @@ func TestCoordinatorClientWaitsForBothAgentsAndMergesLedger(t *testing.T) {
if err != nil || len(ledger) != 2 || ledger[0].ClientMid != "a" || ledger[1].ClientMid != "b" {
t.Fatalf("ledger=%+v err=%v", ledger, err)
}
if err = client.submitObserved(context.Background(), "a", []AcceptedMessage{{ClientMid: "unknown", Seq: 10}}); err != nil {
t.Fatal(err)
}
if err = client.submitObserved(context.Background(), "b", []AcceptedMessage{{ClientMid: "unknown", Seq: 10}}); err != nil {
t.Fatal(err)
}
observed, err := client.observed(context.Background())
if err != nil || len(observed) != 1 || observed[0].ClientMid != "unknown" {
t.Fatalf("observed=%+v err=%v", observed, err)
}
if err = client.submitHistory(context.Background(), "a", []string{"mid-2", "mid-1"}); err != nil {
t.Fatal(err)
}
if err = client.submitHistory(context.Background(), "b", []string{"mid-2", "mid-3"}); err != nil {
t.Fatal(err)
}
history, err := client.history(context.Background())
if err != nil || len(history) != 3 || history[0] != "mid-1" || history[1] != "mid-2" || history[2] != "mid-3" {
t.Fatalf("history=%+v err=%v", history, err)
}
}
func testCoordinatorMux(state *coordinatorState) *http.ServeMux {
@@ -54,6 +117,8 @@ func testCoordinatorMux(state *coordinatorState) *http.ServeMux {
mux.HandleFunc("/v1/ready", state.handleReady)
mux.HandleFunc("/v1/start", state.handleStart)
mux.HandleFunc("/v1/ledger", state.handleLedger)
mux.HandleFunc("/v1/observed", state.handleObserved)
mux.HandleFunc("/v1/history", state.handleHistory)
mux.HandleFunc("/v1/summary", state.handleSummary)
mux.HandleFunc("/v1/status", state.handleStatus)
return mux
+9 -6
View File
@@ -197,15 +197,17 @@ func (a *apiClient) findPendingFriendRequest(ctx context.Context, target Account
func (a *apiClient) createFriendRequest(ctx context.Context, source, target AccountRecord) (*friendRequestResponse, error) {
var result friendRequestResponse
body := map[string]any{"target_user_id": target.UserID, "message": "loadtest", "source": "search", "source_topic": ""}
idempotencyKey := fmt.Sprintf("loadtest-friend-%d-%s-%04d", time.Now().UnixNano(), source.Machine, source.Index)
err := a.do(ctx, http.MethodPost, "/v1/im/friend-requests", source.Token,
fmt.Sprintf("loadtest-friend-%04d", source.Index), body, &result)
idempotencyKey, body, &result)
return &result, err
}
func (a *apiClient) acceptFriendRequest(ctx context.Context, target AccountRecord, requestID string) (*friendRequestResponse, error) {
var result friendRequestResponse
idempotencyKey := fmt.Sprintf("loadtest-friend-accept-%d", time.Now().UnixNano())
err := a.do(ctx, http.MethodPost, "/v1/im/friend-requests/"+url.PathEscape(requestID)+"/accept", target.Token,
"loadtest-friend-accept-"+requestID, map[string]any{}, &result)
idempotencyKey, map[string]any{}, &result)
return &result, err
}
@@ -216,11 +218,12 @@ type p2pResponse struct {
func (a *apiClient) ensureP2P(ctx context.Context, source, target AccountRecord) (*p2pResponse, error) {
var result p2pResponse
// EnsureP2P is itself idempotent and must execute on every preflight so it
// can repair subscriptions and refresh a loaded topic. A stable HTTP
// idempotency key would replay an old response and skip that repair.
// EnsureP2P must execute on every preflight so it can repair subscriptions
// and refresh a loaded topic. Use a fresh key instead of replaying an old
// successful response from a previous load-test run.
idempotencyKey := fmt.Sprintf("loadtest-p2p-%d-%s-%04d", time.Now().UnixNano(), source.Machine, source.Index)
err := a.do(ctx, http.MethodPost, "/v1/im/conversations/p2p", source.Token,
"",
idempotencyKey,
map[string]any{"peer_user_id": target.UserID}, &result)
return &result, err
}
+90 -33
View File
@@ -10,34 +10,43 @@ import (
)
type CombinedReport struct {
RunID string `json:"run_id"`
Scenario string `json:"scenario"`
GeneratedAt time.Time `json:"generated_at"`
Agents []AgentSummary `json:"agents"`
ExpectedConnections int `json:"expected_connections"`
ReadyConnections int `json:"ready_connections"`
PublishAttempted int64 `json:"publish_attempted"`
PublishAccepted int64 `json:"publish_accepted"`
PublishRejected int64 `json:"publish_rejected"`
PublishErrors map[string]int64 `json:"publish_errors,omitempty"`
PublishSuccessRate float64 `json:"publish_success_rate"`
AckP95MS float64 `json:"ack_p95_ms"`
DeliveryP95MS float64 `json:"delivery_p95_ms"`
ExpectedDeliveries int64 `json:"expected_deliveries"`
ReceivedDeliveries int64 `json:"received_deliveries"`
RealtimeLost int64 `json:"realtime_lost"`
RealtimeLostStored int64 `json:"realtime_lost_but_persisted"`
ArrivalRate float64 `json:"arrival_rate"`
LossRate float64 `json:"loss_rate"`
Duplicates int64 `json:"duplicates"`
OutOfOrder int64 `json:"out_of_order"`
Disconnects int64 `json:"disconnects"`
HistoryChecked int64 `json:"history_checked"`
HistoryFound int64 `json:"history_found"`
PersistenceLost int64 `json:"persistence_lost"`
Missing []MissingDelivery `json:"missing,omitempty"`
Passed bool `json:"passed"`
FailureReasons []string `json:"failure_reasons,omitempty"`
RunID string `json:"run_id"`
Scenario string `json:"scenario"`
GeneratedAt time.Time `json:"generated_at"`
Agents []AgentSummary `json:"agents"`
ExpectedConnections int `json:"expected_connections"`
ReadyConnections int `json:"ready_connections"`
PublishAttempted int64 `json:"publish_attempted"`
PublishAccepted int64 `json:"publish_accepted"`
PublishRejected int64 `json:"publish_rejected"`
PublishExplicitReject int64 `json:"publish_explicitly_rejected"`
PublishOutcomeUnknown int64 `json:"publish_outcome_unknown"`
PublishErrors map[string]int64 `json:"publish_errors,omitempty"`
PublishSuccessRate float64 `json:"publish_success_rate"`
AckP95MS float64 `json:"ack_p95_ms"`
DeliveryP95MS float64 `json:"delivery_p95_ms"`
DeliveryLocalP95MS float64 `json:"delivery_local_p95_ms"`
DeliveryCrossP95MS float64 `json:"delivery_cross_machine_p95_ms"`
CrossClockSkewSamples int64 `json:"cross_machine_negative_latency_samples"`
ExpectedDeliveries int64 `json:"expected_deliveries"`
ReceivedDeliveries int64 `json:"received_deliveries"`
RealtimeLost int64 `json:"realtime_lost"`
RealtimeLostStored int64 `json:"realtime_lost_but_persisted"`
ArrivalRate float64 `json:"arrival_rate"`
LossRate float64 `json:"loss_rate"`
Duplicates int64 `json:"duplicates"`
OutOfOrder int64 `json:"out_of_order"`
OrderingViolations []OrderingViolation `json:"ordering_violations,omitempty"`
Disconnects int64 `json:"disconnects"`
UnexpectedMessages int64 `json:"unexpected_messages"`
HistoryChecked int64 `json:"history_checked"`
HistoryFound int64 `json:"history_found"`
HistoryUnackedChecked int64 `json:"history_unacked_observed_checked"`
HistoryUnackedFound int64 `json:"history_unacked_observed_found"`
PersistenceLost int64 `json:"persistence_lost"`
Missing []MissingDelivery `json:"missing,omitempty"`
Passed bool `json:"passed"`
FailureReasons []string `json:"failure_reasons,omitempty"`
}
func runMerge(aPath, bPath, output string) error {
@@ -63,20 +72,33 @@ func runMerge(aPath, bPath, output string) error {
report.ReadyConnections += summary.ReadyConnections
report.PublishAttempted += summary.PublishAttempted
report.PublishAccepted += summary.PublishAccepted
report.PublishRejected += summary.PublishRejected
for category, count := range summary.PublishErrors {
report.PublishErrors[category] += count
}
unknown := summary.PublishOutcomeUnknown
if unknown == 0 {
unknown = summary.PublishErrors["ctrl_timeout"] + summary.PublishErrors["socket"]
}
explicit := summary.PublishErrors["403"] + summary.PublishErrors["502"] +
summary.PublishErrors["503"] + summary.PublishErrors["other"]
report.PublishOutcomeUnknown += unknown
report.PublishExplicitReject += explicit
report.PublishRejected += explicit
report.ExpectedDeliveries += summary.ExpectedPeer + summary.ExpectedGroup
report.ReceivedDeliveries += summary.ReceivedPeer + summary.ReceivedGroup
report.RealtimeLost += summary.RealtimeLost
report.RealtimeLostStored += summary.RealtimeLostStored
report.Duplicates += summary.Duplicates
report.OutOfOrder += summary.OutOfOrder
report.OrderingViolations = append(report.OrderingViolations, summary.OrderingViolations...)
report.Disconnects += summary.Disconnects
report.UnexpectedMessages += summary.UnexpectedMessages
report.HistoryChecked += summary.HistoryChecked
report.HistoryFound += summary.HistoryFound
report.HistoryUnackedChecked += summary.HistoryUnackedChecked
report.HistoryUnackedFound += summary.HistoryUnackedFound
report.PersistenceLost += summary.PersistenceLost
report.CrossClockSkewSamples += summary.CrossClockSkewSamples
report.Missing = append(report.Missing, summary.Missing...)
if len(summary.Errors) > 0 {
report.FailureReasons = append(report.FailureReasons, fmt.Sprintf("agent %s errors=%d", summary.Machine, len(summary.Errors)))
@@ -85,8 +107,15 @@ func runMerge(aPath, bPath, output string) error {
if report.PublishAttempted > 0 {
report.PublishSuccessRate = float64(report.PublishAccepted) / float64(report.PublishAttempted)
}
report.AckP95MS = mergedHistogramP95(report.Agents, func(summary AgentSummary) HistogramSnapshot { return summary.AckLatency })
report.DeliveryP95MS = mergedHistogramP95(report.Agents, func(summary AgentSummary) HistogramSnapshot { return summary.DeliveryLatency })
report.AckP95MS = mergedAckP95(report.Agents)
report.DeliveryLocalP95MS = mergedHistogramP95(report.Agents, func(summary AgentSummary) HistogramSnapshot { return summary.DeliveryLatencyLocal })
report.DeliveryCrossP95MS = mergedHistogramP95(report.Agents, func(summary AgentSummary) HistogramSnapshot { return summary.DeliveryLatencyCross })
// delivery_p95_ms remains the compatibility field, but now uses only same-clock
// samples. Cross-machine wall clocks cannot produce a valid one-way latency.
report.DeliveryP95MS = report.DeliveryLocalP95MS
if report.DeliveryP95MS == 0 {
report.DeliveryP95MS = mergedHistogramP95(report.Agents, func(summary AgentSummary) HistogramSnapshot { return summary.DeliveryLatency })
}
if report.ExpectedDeliveries > 0 {
report.ArrivalRate = float64(report.ReceivedDeliveries) / float64(report.ExpectedDeliveries)
report.LossRate = float64(report.RealtimeLost) / float64(report.ExpectedDeliveries)
@@ -99,14 +128,22 @@ func runMerge(aPath, bPath, output string) error {
report.FailureReasons = append(report.FailureReasons,
fmt.Sprintf("publish success rate=%.6f expected>=0.999900", report.PublishSuccessRate))
}
if report.PublishOutcomeUnknown > 0 {
report.FailureReasons = append(report.FailureReasons,
fmt.Sprintf("publish outcome unknown=%d (ACK timeout or socket failure)", report.PublishOutcomeUnknown))
}
if report.UnexpectedMessages > 0 {
report.FailureReasons = append(report.FailureReasons,
fmt.Sprintf("deliveries observed without ACK ledger=%d", report.UnexpectedMessages))
}
if report.AckP95MS > 500 {
report.FailureReasons = append(report.FailureReasons, fmt.Sprintf("ack p95=%.0fms expected<=500ms", report.AckP95MS))
report.FailureReasons = append(report.FailureReasons, fmt.Sprintf("ack p95=%.3fms expected<=500ms", report.AckP95MS))
}
if report.DeliveryP95MS > 1000 {
report.FailureReasons = append(report.FailureReasons,
fmt.Sprintf("delivery p95=%.0fms expected<=1000ms", report.DeliveryP95MS))
}
if report.PublishRejected > 0 || report.RealtimeLost > 0 || report.PersistenceLost > 0 || report.Duplicates > 0 || report.OutOfOrder > 0 || report.Disconnects > 0 {
if report.PublishRejected > 0 || report.RealtimeLost > 0 || report.PersistenceLost > 0 || report.Duplicates > 0 || report.OutOfOrder > 0 || report.Disconnects > 0 || report.UnexpectedMessages > 0 {
report.FailureReasons = append(report.FailureReasons, "message correctness gate failed")
}
report.Passed = len(report.FailureReasons) == 0
@@ -119,6 +156,26 @@ func runMerge(aPath, bPath, output string) error {
return writeJSONFile(output, report)
}
func mergedAckP95(agents []AgentSummary) float64 {
var expected int64
latencies := make([]float64, 0)
for _, agent := range agents {
expected += agent.AckLatency.Count
for _, message := range agent.AcceptedLocal {
if message.SentAt.IsZero() || message.AckAt.Before(message.SentAt) {
continue
}
latencies = append(latencies, float64(message.AckAt.Sub(message.SentAt))/float64(time.Millisecond))
}
}
if expected == 0 || int64(len(latencies)) != expected {
return mergedHistogramP95(agents, func(summary AgentSummary) HistogramSnapshot { return summary.AckLatency })
}
sort.Float64s(latencies)
index := (len(latencies)*95 + 99) / 100
return latencies[index-1]
}
func mergedHistogramP95(agents []AgentSummary, selectHistogram func(AgentSummary) HistogramSnapshot) float64 {
var count int64
buckets := make([]int64, len(latencyBounds)+1)
+70 -3
View File
@@ -5,18 +5,21 @@ import (
"os"
"path/filepath"
"testing"
"time"
)
func TestRunMergeComputesArrivalAndPersistenceGate(t *testing.T) {
dir := t.TempDir()
a := AgentSummary{RunID: "run1", Scenario: "p2p", Machine: "a", ReadyConnections: 1000,
ExpectedConnections: 1000,
PublishAttempted: 10, PublishAccepted: 10, ExpectedPeer: 10, ReceivedPeer: 9,
RealtimeLost: 1, HistoryChecked: 10, HistoryFound: 10}
PublishAttempted: 10, PublishAccepted: 10, ExpectedPeer: 10, ReceivedPeer: 9, UnexpectedMessages: 42,
PublishErrors: map[string]int64{"ctrl_timeout": 3, "socket": 2, "503": 1},
RealtimeLost: 1, HistoryChecked: 10, HistoryFound: 10}
b := AgentSummary{RunID: "run1", Scenario: "p2p", Machine: "b", ReadyConnections: 1000,
ExpectedConnections: 1000,
PublishAttempted: 10, PublishAccepted: 10, ExpectedPeer: 10, ReceivedPeer: 10,
HistoryChecked: 10, HistoryFound: 9, PersistenceLost: 1}
HistoryChecked: 10, HistoryFound: 9, PersistenceLost: 1, OutOfOrder: 1,
OrderingViolations: []OrderingViolation{{ReceiverMachine: "b", ReceiverIndex: 7, PreviousSeq: 12, CurrentSeq: 11}}}
aPath, bPath, reportPath := filepath.Join(dir, "a.json"), filepath.Join(dir, "b.json"), filepath.Join(dir, "report.json")
writeSummaryForTest(t, aPath, a)
writeSummaryForTest(t, bPath, b)
@@ -37,6 +40,13 @@ func TestRunMergeComputesArrivalAndPersistenceGate(t *testing.T) {
if report.Passed || report.PersistenceLost != 1 {
t.Fatalf("correctness gate did not fail: %+v", report)
}
if report.UnexpectedMessages != 42 || report.PublishOutcomeUnknown != 5 ||
report.PublishExplicitReject != 1 || report.PublishRejected != 1 {
t.Fatalf("ambiguous publish outcomes were not merged: %+v", report)
}
if len(report.OrderingViolations) != 1 || report.OrderingViolations[0].ReceiverIndex != 7 {
t.Fatalf("ordering violations were not merged: %+v", report.OrderingViolations)
}
}
func TestMergedHistogramP95(t *testing.T) {
@@ -49,6 +59,63 @@ func TestMergedHistogramP95(t *testing.T) {
}
}
func TestMergedAckP95UsesExactAcceptedTimestamps(t *testing.T) {
base := time.Date(2026, 9, 27, 8, 0, 0, 0, time.UTC)
agents := []AgentSummary{{
AckLatency: HistogramSnapshot{Count: 4, MaxMS: 900, Buckets: map[string]int64{"1s": 4}},
AcceptedLocal: []AcceptedMessage{
{SentAt: base, AckAt: base.Add(100 * time.Millisecond)},
{SentAt: base, AckAt: base.Add(200 * time.Millisecond)},
{SentAt: base, AckAt: base.Add(300 * time.Millisecond)},
{SentAt: base, AckAt: base.Add(400 * time.Millisecond)},
},
}}
if got := mergedAckP95(agents); got != 400 {
t.Fatalf("exact p95=%v want=400", got)
}
}
func TestMergedAckP95FallsBackWhenRawSamplesAreIncomplete(t *testing.T) {
agents := []AgentSummary{{
AckLatency: HistogramSnapshot{Count: 2, MaxMS: 900, Buckets: map[string]int64{"1s": 2}},
AcceptedLocal: []AcceptedMessage{{SentAt: time.Now(), AckAt: time.Now().Add(100 * time.Millisecond)}},
}}
if got := mergedAckP95(agents); got != 1000 {
t.Fatalf("fallback p95=%v want=1000", got)
}
}
func TestRunMergeUsesSameClockDeliveryLatencyForGate(t *testing.T) {
dir := t.TempDir()
local := HistogramSnapshot{Count: 10, MaxMS: 200, Buckets: map[string]int64{"200ms": 10}}
cross := HistogramSnapshot{Count: 10, MaxMS: 5000, Buckets: map[string]int64{"5s": 10}}
a := AgentSummary{RunID: "run1", Scenario: "p2p", Machine: "a", ExpectedConnections: 1,
ReadyConnections: 1, PublishAttempted: 1, PublishAccepted: 1, ExpectedPeer: 1, ReceivedPeer: 1,
DeliveryLatencyLocal: local, DeliveryLatencyCross: cross, CrossClockSkewSamples: 3}
b := a
b.Machine = "b"
aPath, bPath, reportPath := filepath.Join(dir, "a.json"), filepath.Join(dir, "b.json"), filepath.Join(dir, "report.json")
writeSummaryForTest(t, aPath, a)
writeSummaryForTest(t, bPath, b)
if err := runMerge(aPath, bPath, reportPath); err != nil {
t.Fatal(err)
}
var report CombinedReport
raw, err := os.ReadFile(reportPath)
if err != nil {
t.Fatal(err)
}
if err = json.Unmarshal(raw, &report); err != nil {
t.Fatal(err)
}
if report.DeliveryP95MS != 200 || report.DeliveryLocalP95MS != 200 || report.DeliveryCrossP95MS != 5000 {
t.Fatalf("unexpected delivery percentiles: %+v", report)
}
if report.CrossClockSkewSamples != 6 || !report.Passed {
t.Fatalf("cross-machine clock skew must not fail the same-clock latency gate: %+v", report)
}
}
func writeSummaryForTest(t *testing.T, path string, summary AgentSummary) {
t.Helper()
raw, err := json.Marshal(summary)
+92 -34
View File
@@ -25,6 +25,7 @@ type Config struct {
PrepareWorkers int `json:"prepare_workers"`
ConnectRamp string `json:"connect_ramp"`
CommandTimeout string `json:"command_timeout"`
WSPingInterval string `json:"ws_ping_interval"`
P2PMessages int `json:"p2p_messages_per_user"`
P2PInterval string `json:"p2p_interval"`
DrainP2P string `json:"drain_p2p"`
@@ -32,7 +33,12 @@ type Config struct {
PhonePrefix string `json:"registration_phone_prefix"`
VerifyCodeEnv string `json:"verification_code_env"`
HistoryWorkers int `json:"history_workers"`
HistoryPageSize int `json:"history_page_size"`
HistoryInterval string `json:"history_query_interval"`
HistoryPageWait string `json:"history_page_wait"`
HistorySettle string `json:"history_settle"`
HistoryTimeout string `json:"history_timeout"`
CloseRamp string `json:"close_ramp"`
PreflightWorkers int `json:"preflight_workers"`
SkipP2PPreflight bool `json:"skip_p2p_preflight"`
}
@@ -61,6 +67,12 @@ func loadConfig(path string) (*Config, error) {
if cfg.CommandTimeout == "" {
cfg.CommandTimeout = "20s"
}
if cfg.WSPingInterval == "" {
cfg.WSPingInterval = "20s"
}
if _, err = configDuration(cfg.WSPingInterval); err != nil {
return nil, fmt.Errorf("ws_ping_interval: %w", err)
}
if cfg.P2PMessages <= 0 {
cfg.P2PMessages = 10
}
@@ -74,17 +86,47 @@ func loadConfig(path string) (*Config, error) {
cfg.DrainGroup = "60s"
}
if cfg.HistoryWorkers <= 0 {
cfg.HistoryWorkers = 48
cfg.HistoryWorkers = 12
}
if cfg.HistoryWorkers > 128 {
return nil, errors.New("history_workers must not exceed 128")
}
if cfg.HistoryPageSize <= 0 {
cfg.HistoryPageSize = 10
}
if cfg.HistoryPageSize > 200 {
return nil, errors.New("history_page_size must not exceed 200")
}
if cfg.HistoryInterval == "" {
cfg.HistoryInterval = "500ms"
}
if _, err = configDuration(cfg.HistoryInterval); err != nil {
return nil, fmt.Errorf("history_query_interval: %w", err)
}
if cfg.HistoryPageWait == "" {
cfg.HistoryPageWait = "3s"
}
if _, err = configDuration(cfg.HistoryPageWait); err != nil {
return nil, fmt.Errorf("history_page_wait: %w", err)
}
if cfg.HistoryTimeout == "" {
cfg.HistoryTimeout = "3m"
}
if cfg.HistorySettle == "" {
cfg.HistorySettle = "5m"
}
if _, err = configDuration(cfg.HistorySettle); err != nil {
return nil, fmt.Errorf("history_settle: %w", err)
}
if _, err = configDuration(cfg.HistoryTimeout); err != nil {
return nil, fmt.Errorf("history_timeout: %w", err)
}
if cfg.CloseRamp == "" {
cfg.CloseRamp = "10s"
}
if _, err = configDuration(cfg.CloseRamp); err != nil {
return nil, fmt.Errorf("close_ramp: %w", err)
}
if cfg.PreflightWorkers <= 0 {
cfg.PreflightWorkers = 32
}
@@ -343,39 +385,55 @@ type AcceptedMessage struct {
}
type AgentSummary struct {
RunID string `json:"run_id"`
Scenario string `json:"scenario"`
Machine string `json:"machine"`
StartedAt time.Time `json:"started_at"`
LoadEndedAt time.Time `json:"load_ended_at"`
FinishedAt time.Time `json:"finished_at"`
ExpectedConnections int `json:"expected_connections"`
ReadyConnections int `json:"ready_connections"`
PublishAttempted int64 `json:"publish_attempted"`
PublishAccepted int64 `json:"publish_accepted"`
PublishRejected int64 `json:"publish_rejected"`
PublishErrors map[string]int64 `json:"publish_errors,omitempty"`
ExpectedPeer int64 `json:"expected_peer"`
ReceivedPeer int64 `json:"received_peer"`
ExpectedEcho int64 `json:"expected_echo"`
ReceivedEcho int64 `json:"received_echo"`
ExpectedGroup int64 `json:"expected_group"`
ReceivedGroup int64 `json:"received_group"`
RealtimeLost int64 `json:"realtime_lost"`
RealtimeLostStored int64 `json:"realtime_lost_but_persisted"`
Duplicates int64 `json:"duplicates"`
OutOfOrder int64 `json:"out_of_order"`
Disconnects int64 `json:"disconnects"`
UnexpectedMessages int64 `json:"unexpected_messages"`
HistoryChecked int64 `json:"history_checked"`
HistoryFound int64 `json:"history_found"`
PersistenceLost int64 `json:"persistence_lost"`
AckLatency HistogramSnapshot `json:"ack_latency"`
DeliveryLatency HistogramSnapshot `json:"delivery_latency"`
Errors []string `json:"errors,omitempty"`
Missing []MissingDelivery `json:"missing,omitempty"`
PersistenceMissing []string `json:"persistence_missing,omitempty"`
AcceptedLocal []AcceptedMessage `json:"accepted_local,omitempty"`
RunID string `json:"run_id"`
Scenario string `json:"scenario"`
Machine string `json:"machine"`
StartedAt time.Time `json:"started_at"`
LoadEndedAt time.Time `json:"load_ended_at"`
FinishedAt time.Time `json:"finished_at"`
ExpectedConnections int `json:"expected_connections"`
ReadyConnections int `json:"ready_connections"`
PublishAttempted int64 `json:"publish_attempted"`
PublishAccepted int64 `json:"publish_accepted"`
PublishRejected int64 `json:"publish_rejected"`
PublishOutcomeUnknown int64 `json:"publish_outcome_unknown"`
PublishErrors map[string]int64 `json:"publish_errors,omitempty"`
ExpectedPeer int64 `json:"expected_peer"`
ReceivedPeer int64 `json:"received_peer"`
ExpectedEcho int64 `json:"expected_echo"`
ReceivedEcho int64 `json:"received_echo"`
ExpectedGroup int64 `json:"expected_group"`
ReceivedGroup int64 `json:"received_group"`
RealtimeLost int64 `json:"realtime_lost"`
RealtimeLostStored int64 `json:"realtime_lost_but_persisted"`
Duplicates int64 `json:"duplicates"`
OutOfOrder int64 `json:"out_of_order"`
OrderingViolations []OrderingViolation `json:"ordering_violations,omitempty"`
Disconnects int64 `json:"disconnects"`
UnexpectedMessages int64 `json:"unexpected_messages"`
HistoryChecked int64 `json:"history_checked"`
HistoryFound int64 `json:"history_found"`
HistoryUnackedChecked int64 `json:"history_unacked_observed_checked"`
HistoryUnackedFound int64 `json:"history_unacked_observed_found"`
PersistenceLost int64 `json:"persistence_lost"`
AckLatency HistogramSnapshot `json:"ack_latency"`
DeliveryLatency HistogramSnapshot `json:"delivery_latency"`
DeliveryLatencyLocal HistogramSnapshot `json:"delivery_latency_local"`
DeliveryLatencyCross HistogramSnapshot `json:"delivery_latency_cross_machine"`
CrossClockSkewSamples int64 `json:"cross_machine_negative_latency_samples"`
Errors []string `json:"errors,omitempty"`
Missing []MissingDelivery `json:"missing,omitempty"`
PersistenceMissing []string `json:"persistence_missing,omitempty"`
AcceptedLocal []AcceptedMessage `json:"accepted_local,omitempty"`
}
type OrderingViolation struct {
ReceiverMachine string `json:"receiver_machine"`
ReceiverIndex int `json:"receiver_index"`
TopicHash string `json:"topic_hash"`
PreviousSeq int64 `json:"previous_seq"`
CurrentSeq int64 `json:"current_seq"`
ClientMid string `json:"client_mid"`
}
type MissingDelivery struct {
+4 -1
View File
@@ -16,7 +16,10 @@ func TestLoadConfigAppliesBoundedHistoryDefaults(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if cfg.HistoryWorkers != 48 || cfg.HistoryTimeout != "3m" || cfg.PreflightWorkers != 32 {
if cfg.HistoryWorkers != 12 || cfg.HistoryPageSize != 10 || cfg.HistoryInterval != "500ms" ||
cfg.HistoryPageWait != "3s" || cfg.CloseRamp != "10s" ||
cfg.HistorySettle != "5m" || cfg.HistoryTimeout != "3m" ||
cfg.PreflightWorkers != 32 || cfg.WSPingInterval != "20s" {
t.Fatalf("unexpected defaults: %+v", cfg)
}
}
+14 -3
View File
@@ -127,12 +127,23 @@ func prepareFriendships(ctx context.Context, cfg *Config, manifest *Manifest, ap
err := parallelAccounts(ctx, pairs, cfg.PrepareWorkers, func(a AccountRecord) error {
return retryPrepareOperation(ctx, fmt.Sprintf("friend-pair-%04d", a.Index), func() error {
b := *accounts[accountKey("b", a.Index)]
if a.P2PTopic != "" && b.P2PTopic == a.P2PTopic {
return nil
}
if a.UserID == "" || b.UserID == "" {
return fmt.Errorf("pair %d has missing user ID", a.Index)
}
conversation, conversationErr := api.ensureP2P(ctx, a, b)
if conversationErr == nil && conversation.Relationship == "friend" && conversation.Topic != "" {
mu.Lock()
accounts[accountKey("a", a.Index)].P2PTopic = conversation.Topic
accounts[accountKey("b", a.Index)].P2PTopic = conversation.Topic
mu.Unlock()
return nil
}
if conversationErr != nil {
var apiErr *apiError
if !errors.As(conversationErr, &apiErr) || apiErr.Status != http.StatusForbidden {
return fmt.Errorf("check friend pair %d: %w", a.Index, conversationErr)
}
}
request, requestErr := api.createFriendRequest(ctx, a, b)
if requestErr == nil && request.Status == "pending" {
if _, requestErr = api.acceptFriendRequest(ctx, b, request.RequestID); requestErr != nil {
+132 -23
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"math/bits"
"sort"
"strings"
"sync"
"sync/atomic"
@@ -11,27 +12,35 @@ import (
)
type observation struct {
topic string
seq int64
bits []uint64
topic string
seq int64
senderMachine string
senderIndex int
scenario string
sentAt int64
bits []uint64
}
type deliveryRecorder struct {
machine string
localUsers int
latency *DurationHistogram
disconnects atomic.Int64
received atomic.Int64
runID atomic.Value
historyMode atomic.Bool
machine string
localUsers int
latency *DurationHistogram
localLatency *DurationHistogram
crossLatency *DurationHistogram
disconnects atomic.Int64
received atomic.Int64
runID atomic.Value
historyMode atomic.Bool
mu sync.Mutex
observed map[string]*observation
lastSeq []map[string]int64
duplicates int64
outOfOrder int64
malformed int64
history map[string]bool
mu sync.Mutex
observed map[string]*observation
lastSeq []map[string]int64
duplicates int64
outOfOrder int64
ordering []OrderingViolation
malformed int64
crossClockSkewSamples int64
history map[string]bool
}
func newDeliveryRecorder(runID, machine string, localUsers int) *deliveryRecorder {
@@ -41,6 +50,7 @@ func newDeliveryRecorder(runID, machine string, localUsers int) *deliveryRecorde
}
recorder := &deliveryRecorder{
machine: machine, localUsers: localUsers, latency: NewDurationHistogram(),
localLatency: NewDurationHistogram(), crossLatency: NewDurationHistogram(),
observed: make(map[string]*observation), lastSeq: lastSeq, history: make(map[string]bool),
}
recorder.runID.Store(runID)
@@ -49,6 +59,8 @@ func newDeliveryRecorder(runID, machine string, localUsers int) *deliveryRecorde
func (r *deliveryRecorder) setRunID(runID string) { r.runID.Store(runID) }
func (r *deliveryRecorder) resetDisconnects() { r.disconnects.Store(0) }
func (r *deliveryRecorder) onData(account AccountRecord, data dataPacket) {
runID, _ := r.runID.Load().(string)
mid, _ := data.Head["x-im-client-mid"].(string)
@@ -56,8 +68,11 @@ func (r *deliveryRecorder) onData(account AccountRecord, data dataPacket) {
return
}
var content struct {
RunID string `json:"run_id"`
SentAt int64 `json:"sent_at_unix_nano"`
RunID string `json:"run_id"`
SenderMachine string `json:"sender_machine"`
SenderIndex int `json:"sender_index"`
Scenario string `json:"scenario"`
SentAt int64 `json:"sent_at_unix_nano"`
}
if json.Unmarshal(data.Content, &content) != nil || content.RunID != runID {
r.mu.Lock()
@@ -79,7 +94,9 @@ func (r *deliveryRecorder) onData(account AccountRecord, data dataPacket) {
defer r.mu.Unlock()
obs := r.observed[mid]
if obs == nil {
obs = &observation{topic: data.Topic, seq: data.Seq, bits: make([]uint64, (r.localUsers+63)/64)}
obs = &observation{topic: data.Topic, seq: data.Seq, senderMachine: content.SenderMachine,
senderIndex: content.SenderIndex, scenario: content.Scenario, sentAt: content.SentAt,
bits: make([]uint64, (r.localUsers+63)/64)}
r.observed[mid] = obs
}
word, bit := index/64, uint(index%64)
@@ -92,15 +109,46 @@ func (r *deliveryRecorder) onData(account AccountRecord, data dataPacket) {
r.received.Add(1)
if previous := r.lastSeq[index][data.Topic]; previous > 0 && data.Seq < previous {
r.outOfOrder++
if len(r.ordering) < 100 {
r.ordering = append(r.ordering, OrderingViolation{
ReceiverMachine: r.machine,
ReceiverIndex: account.Index,
TopicHash: topicHash(data.Topic),
PreviousSeq: previous,
CurrentSeq: data.Seq,
ClientMid: mid,
})
}
}
if data.Seq > r.lastSeq[index][data.Topic] {
r.lastSeq[index][data.Topic] = data.Seq
}
if content.SentAt > 0 {
r.latency.Observe(time.Since(time.Unix(0, content.SentAt)))
delay := time.Since(time.Unix(0, content.SentAt))
r.latency.Observe(delay)
senderMachine := content.SenderMachine
if senderMachine == "" {
senderMachine = senderMachineFromClientMid(runID, mid)
}
if senderMachine == r.machine {
r.localLatency.Observe(delay)
} else if delay < 0 {
r.crossClockSkewSamples++
} else {
r.crossLatency.Observe(delay)
}
}
}
func senderMachineFromClientMid(runID, mid string) string {
prefix := strings.TrimPrefix(mid, runID+"-")
parts := strings.SplitN(prefix, "-", 3)
if len(parts) < 2 {
return ""
}
return parts[1]
}
func (r *deliveryRecorder) metricCounts() (received, duplicates, outOfOrder, disconnects int64) {
r.mu.Lock()
duplicates = r.duplicates
@@ -117,12 +165,67 @@ func (r *deliveryRecorder) historyFound(mid string) bool {
return r.history[mid]
}
func (r *deliveryRecorder) historyFoundAll(messageIDs []string) bool {
r.mu.Lock()
defer r.mu.Unlock()
for _, mid := range messageIDs {
if !r.history[mid] {
return false
}
}
return true
}
func (r *deliveryRecorder) unackedObservedMessages(ledger []AcceptedMessage, scenario string) []AcceptedMessage {
accepted := make(map[string]bool, len(ledger))
for _, message := range ledger {
accepted[message.ClientMid] = true
}
runID, _ := r.runID.Load().(string)
r.mu.Lock()
defer r.mu.Unlock()
messages := make([]AcceptedMessage, 0)
for mid, obs := range r.observed {
if accepted[mid] || obs.seq <= 0 || obs.topic == "" {
continue
}
kind := "p2p"
if strings.HasPrefix(obs.topic, "grp") {
kind = "group"
}
observedScenario := obs.scenario
if observedScenario == "" {
observedScenario = scenario
}
message := AcceptedMessage{RunID: runID, Scenario: observedScenario, Kind: kind,
ClientMid: mid, Topic: obs.topic, Seq: obs.seq, SenderMachine: obs.senderMachine,
SenderIndex: obs.senderIndex, PeerMachine: r.machine, PeerIndex: firstObservedIndex(obs.bits)}
if obs.sentAt > 0 {
message.SentAt = time.Unix(0, obs.sentAt).UTC()
}
messages = append(messages, message)
}
sort.Slice(messages, func(i, j int) bool { return messages[i].ClientMid < messages[j].ClientMid })
return messages
}
func firstObservedIndex(words []uint64) int {
for wordIndex, word := range words {
if word != 0 {
return wordIndex*64 + bits.TrailingZeros64(word) + 1
}
}
return 0
}
func (r *deliveryRecorder) onDisconnect(_ AccountRecord, _ error) {
r.disconnects.Add(1)
}
func (r *deliveryRecorder) summarize(base AgentSummary, ledger []AcceptedMessage) AgentSummary {
base.DeliveryLatency = r.latency.Snapshot()
base.DeliveryLatencyLocal = r.localLatency.Snapshot()
base.DeliveryLatencyCross = r.crossLatency.Snapshot()
base.Disconnects = r.disconnects.Load()
runID, _ := r.runID.Load().(string)
accepted := make(map[string]AcceptedMessage, len(ledger))
@@ -132,6 +235,8 @@ func (r *deliveryRecorder) summarize(base AgentSummary, ledger []AcceptedMessage
defer r.mu.Unlock()
base.Duplicates = r.duplicates
base.OutOfOrder = r.outOfOrder
base.CrossClockSkewSamples = r.crossClockSkewSamples
base.OrderingViolations = append([]OrderingViolation(nil), r.ordering...)
if r.malformed > 0 {
base.Errors = append(base.Errors, fmt.Sprintf("malformed run messages=%d", r.malformed))
}
@@ -180,13 +285,17 @@ func (r *deliveryRecorder) summarize(base AgentSummary, ledger []AcceptedMessage
return base
}
func (r *deliveryRecorder) countRealtimeLostStored(ledger []AcceptedMessage) int64 {
func (r *deliveryRecorder) countRealtimeLostStored(ledger []AcceptedMessage, storedMessageIDs []string) int64 {
runID, _ := r.runID.Load().(string)
stored := make(map[string]bool, len(storedMessageIDs))
for _, mid := range storedMessageIDs {
stored[mid] = true
}
r.mu.Lock()
defer r.mu.Unlock()
var count int64
for _, message := range ledger {
if message.RunID != runID || !r.history[message.ClientMid] {
if message.RunID != runID || !stored[message.ClientMid] {
continue
}
obs := r.observed[message.ClientMid]
+67 -2
View File
@@ -43,13 +43,78 @@ func TestDeliveryRecorderSeparatesHistoryFromRealtime(t *testing.T) {
if !recorder.historyFound(message.ClientMid) {
t.Fatal("history message was not recorded")
}
if got := recorder.countRealtimeLostStored([]AcceptedMessage{message}); got != 1 {
if got := recorder.countRealtimeLostStored([]AcceptedMessage{message}, []string{message.ClientMid}); got != 1 {
t.Fatalf("realtime lost but persisted=%d", got)
}
}
func TestDeliveryRecorderUsesSharedHistoryForRealtimeLostStored(t *testing.T) {
recorder := newDeliveryRecorder("run1", "b", 1)
message := AcceptedMessage{RunID: "run1", Kind: "group", ClientMid: "run1-g-a-0001-01", Topic: "grp-one", Seq: 10}
if got := recorder.countRealtimeLostStored([]AcceptedMessage{message}, []string{message.ClientMid}); got != 1 {
t.Fatalf("shared history count=%d want=1", got)
}
if got := recorder.countRealtimeLostStored([]AcceptedMessage{message}, nil); got != 0 {
t.Fatalf("empty shared history count=%d want=0", got)
}
}
func TestDeliveryRecorderExportsObservedMessagesWithoutAck(t *testing.T) {
recorder := newDeliveryRecorder("run1", "b", 2)
acked := AcceptedMessage{RunID: "run1", Scenario: "group", Kind: "group", ClientMid: "run1-g-a-0001-01",
Topic: "grp-one", Seq: 10, SenderMachine: "a", SenderIndex: 1}
unacked := AcceptedMessage{RunID: "run1", Scenario: "group", Kind: "group", ClientMid: "run1-g-a-0002-01",
Topic: "grp-one", Seq: 11, SenderMachine: "a", SenderIndex: 2}
recorder.onData(AccountRecord{Index: 1}, testData(acked, time.Now()))
recorder.onData(AccountRecord{Index: 2}, testData(unacked, time.Now()))
messages := recorder.unackedObservedMessages([]AcceptedMessage{acked}, "group")
if len(messages) != 1 || messages[0].ClientMid != unacked.ClientMid || messages[0].Seq != 11 ||
messages[0].PeerMachine != "b" || messages[0].PeerIndex != 2 || messages[0].SenderIndex != 2 {
t.Fatalf("unexpected observed ledger: %+v", messages)
}
}
func TestDeliveryRecorderCapturesOrderingViolationDetails(t *testing.T) {
recorder := newDeliveryRecorder("run1", "b", 1)
newer := AcceptedMessage{RunID: "run1", ClientMid: "run1-p-a-0001-02", Topic: "p2p-one", Seq: 12}
older := AcceptedMessage{RunID: "run1", ClientMid: "run1-p-a-0001-01", Topic: "p2p-one", Seq: 11}
recorder.onData(AccountRecord{Index: 1}, testData(newer, time.Now()))
recorder.onData(AccountRecord{Index: 1}, testData(older, time.Now()))
summary := recorder.summarize(AgentSummary{}, nil)
if summary.OutOfOrder != 1 || len(summary.OrderingViolations) != 1 {
t.Fatalf("out_of_order=%d violations=%+v", summary.OutOfOrder, summary.OrderingViolations)
}
violation := summary.OrderingViolations[0]
if violation.ReceiverMachine != "b" || violation.ReceiverIndex != 1 ||
violation.PreviousSeq != 12 || violation.CurrentSeq != 11 || violation.ClientMid != older.ClientMid {
t.Fatalf("unexpected violation: %+v", violation)
}
}
func TestDeliveryRecorderSeparatesLocalAndCrossMachineLatency(t *testing.T) {
recorder := newDeliveryRecorder("run1", "a", 1)
local := AcceptedMessage{RunID: "run1", ClientMid: "run1-p-a-0001-01", Topic: "p2p-one", Seq: 1}
cross := AcceptedMessage{RunID: "run1", ClientMid: "run1-p-b-0001-01", Topic: "p2p-one", Seq: 2}
recorder.onData(AccountRecord{Index: 1}, testData(local, time.Now().Add(-10*time.Millisecond)))
recorder.onData(AccountRecord{Index: 1}, testData(cross, time.Now().Add(time.Second)))
summary := recorder.summarize(AgentSummary{}, nil)
if summary.DeliveryLatencyLocal.Count != 1 || summary.DeliveryLatencyCross.Count != 0 {
t.Fatalf("unexpected split latency counts: local=%d cross=%d",
summary.DeliveryLatencyLocal.Count, summary.DeliveryLatencyCross.Count)
}
if summary.CrossClockSkewSamples != 1 {
t.Fatalf("cross clock skew samples=%d", summary.CrossClockSkewSamples)
}
}
func testData(message AcceptedMessage, sentAt time.Time) dataPacket {
content, _ := json.Marshal(map[string]any{"run_id": message.RunID, "sent_at_unix_nano": sentAt.UnixNano()})
content, _ := json.Marshal(map[string]any{"run_id": message.RunID, "scenario": message.Scenario,
"sender_machine": message.SenderMachine, "sender_index": message.SenderIndex,
"sent_at_unix_nano": sentAt.UnixNano()})
return dataPacket{Topic: message.Topic, Seq: message.Seq,
Head: map[string]any{"x-im-client-mid": message.ClientMid}, Content: content}
}
+43 -2
View File
@@ -116,6 +116,12 @@ func connectWSClient(ctx context.Context, cfg *Config, account AccountRecord, ap
return nil, fmt.Errorf("login %s: %w", account.Username, err)
}
client.applyLogin(ctrl)
pingInterval, err := configDuration(cfg.WSPingInterval)
if err != nil {
client.Close()
return nil, err
}
client.startKeepalive(pingInterval)
return client, nil
}
@@ -245,7 +251,8 @@ func (c *WSClient) Publish(ctx context.Context, topic, clientMid, runID, scenari
"id": id, "topic": topic, "noecho": false,
"head": map[string]any{"mime": "application/json", "x-im-schema": 1, "x-im-type": "text", "x-im-client-mid": clientMid},
"content": map[string]any{"text": "loadtest", "run_id": runID, "scenario": scenario,
"sender_index": c.account.Index, "message_index": messageIndex, "sent_at_unix_nano": sentAt.UnixNano()},
"sender_machine": c.account.Machine, "sender_index": c.account.Index,
"message_index": messageIndex, "sent_at_unix_nano": sentAt.UnixNano()},
}})
if err != nil {
return AcceptedMessage{}, err
@@ -337,7 +344,10 @@ func (c *WSClient) removeWait(id string) {
}
func (c *WSClient) readLoop() {
defer close(c.done)
defer func() {
_ = c.conn.Close()
close(c.done)
}()
for {
var envelope map[string]json.RawMessage
if err := c.conn.ReadJSON(&envelope); err != nil {
@@ -368,6 +378,37 @@ func (c *WSClient) readLoop() {
}
}
func (c *WSClient) startKeepalive(interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
deadline := time.Now().Add(interval / 2)
if err := c.conn.WriteControl(websocket.PingMessage, nil, deadline); err != nil {
_ = c.conn.Close()
return
}
case <-c.done:
return
}
}
}()
}
func (c *WSClient) alive() bool {
if c == nil || c.closed.Load() {
return false
}
select {
case <-c.done:
return false
default:
return true
}
}
func (c *WSClient) Close() {
if c == nil || !c.closed.CompareAndSwap(false, true) {
return
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
func TestWSClientKeepaliveSendsPing(t *testing.T) {
ping := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
conn.SetPingHandler(func(string) error {
select {
case ping <- struct{}{}:
default:
}
return conn.WriteControl(websocket.PongMessage, nil, time.Now().Add(time.Second))
})
for {
if _, _, err = conn.ReadMessage(); err != nil {
return
}
}
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
client := &WSClient{conn: conn, waits: make(map[string]chan ctrlPacket), done: make(chan struct{})}
go client.readLoop()
client.startKeepalive(10 * time.Millisecond)
defer client.Close()
select {
case <-ping:
case <-time.After(time.Second):
t.Fatal("websocket keepalive ping was not received")
}
}
func TestWSClientReadLoopClosesConnectionAfterDecodeError(t *testing.T) {
serverSawClose := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
if err = conn.WriteMessage(websocket.TextMessage, []byte("{")); err != nil {
return
}
if _, _, err = conn.ReadMessage(); err != nil {
serverSawClose <- struct{}{}
}
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
client := &WSClient{conn: conn, waits: make(map[string]chan ctrlPacket), done: make(chan struct{})}
go client.readLoop()
select {
case <-client.done:
case <-time.After(time.Second):
t.Fatal("read loop did not exit after malformed frame")
}
select {
case <-serverSawClose:
case <-time.After(time.Second):
t.Fatal("read loop exited without closing the underlying connection")
}
}
+14
View File
@@ -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:
+2
View File
@@ -743,7 +743,9 @@ func (Cluster) TopicProxy(msg *ClusterResp, unused *bool) error {
msg.SrvMsg.uid = types.ParseUserId(msg.SrvMsg.AsUser)
select {
case t.proxy <- msg:
immetrics.ObserveInternalQueueEnqueue("cluster_topic_proxy", len(t.proxy), cap(t.proxy), true)
default:
immetrics.ObserveInternalQueueEnqueue("cluster_topic_proxy", len(t.proxy), cap(t.proxy), false)
logs.Warn.Printf("cluster: proxy channel full, topic %s", msg.RcptTo)
}
} else {
+6
View File
@@ -260,6 +260,12 @@ type TenantRoutedAdapter interface {
TenantAdapterFor(t.TenantID) (TenantBusinessAdapter, TenantAuthAdapter, error)
}
// TenantRouteCacheInvalidator clears cached tenant routing metadata. A zero
// tenant ID clears every cached route.
type TenantRouteCacheInvalidator interface {
InvalidateTenantRoute(t.TenantID)
}
type TenantMessageBatchAdapter interface {
TenantMessageSaveBatch(t.TenantID, []*t.Message) error
TenantGroupMessageSaveBatch(t.TenantID, []*t.Message) error
+105 -40
View File
@@ -21,8 +21,10 @@ 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"
"golang.org/x/sync/singleflight"
)
// adapter holds MySQL connection data.
@@ -42,14 +44,17 @@ type adapter struct {
// DB transaction timeout.
txTimeout time.Duration
baseConfig *configType
tenantDBRouting bool
tenantScopedDB bool
tenantID t.TenantID
tenantRegistryDB string
tenantAdapters map[t.TenantID]*adapter
tenantMu sync.Mutex
groupShardWindow string
baseConfig *configType
tenantDBRouting bool
tenantScopedDB bool
tenantID t.TenantID
tenantRegistryDB string
tenantAdapters map[t.TenantID]*adapter
tenantRouteExpiry map[t.TenantID]time.Time
tenantRouteGen uint64
tenantRouteRefresh singleflight.Group
tenantMu sync.Mutex
groupShardWindow string
}
const (
@@ -62,6 +67,7 @@ const (
defaultMaxResults = 1024
// This is capped by the Session's send queue limit (128).
defaultMaxMessageResults = 100
tenantRouteCacheTTL = time.Minute
// If DB request timeout is specified,
// we allocate txTimeoutMultiplier times more time for transactions.
@@ -109,6 +115,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 +194,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
}
@@ -257,6 +267,7 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
a.tenantID = defaultTenantID
a.tenantRegistryDB = strings.TrimSpace(config.TenantRegistryDB)
a.tenantAdapters = make(map[t.TenantID]*adapter)
a.tenantRouteExpiry = make(map[t.TenantID]time.Time)
if a.maxResults <= 0 {
a.maxResults = defaultMaxResults
@@ -281,7 +292,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()
}
@@ -308,6 +326,8 @@ func (a *adapter) Close() error {
}
delete(a.tenantAdapters, tenantID)
}
a.tenantRouteExpiry = make(map[t.TenantID]time.Time)
a.tenantRouteGen++
a.tenantMu.Unlock()
if a.registryDB != nil {
if closeErr := a.registryDB.Close(); err == nil {
@@ -463,35 +483,62 @@ func (a *adapter) TenantListActive(limit int) ([]t.Tenant, error) {
}
func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusinessAdapter, dbadapter.TenantAuthAdapter, error) {
if !a.tenantDBRouting {
if a.tenantID != defaultTenantID && a.tenantID != tenantID {
return nil, nil, t.ErrMalformed
}
if a.registryDB != nil {
tenant, err := a.tenantByID(tenantID)
if err != nil {
return nil, nil, err
}
if tenant.State != t.TenantStateActive {
return nil, nil, t.ErrPermissionDenied
}
}
return a, a, nil
if !tenantID.IsValid() || (!a.tenantDBRouting && a.tenantID != defaultTenantID && a.tenantID != tenantID) {
return nil, nil, t.ErrMalformed
}
tenant, err := a.tenantByID(tenantID)
if tenantAdp, ok := a.cachedTenantAdapter(tenantID, time.Now()); ok {
return tenantAdp, tenantAdp, nil
}
resolved, err, _ := a.tenantRouteRefresh.Do(strconv.FormatInt(int64(tenantID), 10), func() (any, error) {
if tenantAdp, ok := a.cachedTenantAdapter(tenantID, time.Now()); ok {
return tenantAdp, nil
}
generation := a.tenantRouteGeneration()
if a.registryDB != nil {
if _, err := a.tenantByID(tenantID); err != nil {
return nil, err
}
}
tenantAdp := a.tenantAdapter(tenantID)
a.cacheTenantAdapter(tenantID, tenantAdp, generation, time.Now().Add(tenantRouteCacheTTL))
return tenantAdp, nil
})
if err != nil {
return nil, nil, err
}
if tenant.State != t.TenantStateActive {
return nil, nil, t.ErrPermissionDenied
tenantAdp := resolved.(*adapter)
return tenantAdp, tenantAdp, nil
}
func (a *adapter) cachedTenantAdapter(tenantID t.TenantID, now time.Time) (*adapter, bool) {
a.tenantMu.Lock()
defer a.tenantMu.Unlock()
if expiresAt, ok := a.tenantRouteExpiry[tenantID]; !ok || !now.Before(expiresAt) {
return nil, false
}
if !a.tenantDBRouting {
return a, true
}
tenantAdp := a.tenantAdapters[tenantID]
return tenantAdp, tenantAdp != nil
}
func (a *adapter) tenantRouteGeneration() uint64 {
a.tenantMu.Lock()
defer a.tenantMu.Unlock()
return a.tenantRouteGen
}
func (a *adapter) tenantAdapter(tenantID t.TenantID) *adapter {
if !a.tenantDBRouting {
return a
}
a.tenantMu.Lock()
if tenantAdp := a.tenantAdapters[tenantID]; tenantAdp != nil {
a.tenantMu.Unlock()
return tenantAdp, tenantAdp, nil
defer a.tenantMu.Unlock()
if existing := a.tenantAdapters[tenantID]; existing != nil {
return existing
}
a.tenantMu.Unlock()
registryDB := a.registryDB
if registryDB == nil {
registryDB = a.db
@@ -513,14 +560,32 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
// explicit tenant_id writes enabled for logical isolation.
tenantScopedDB: false,
}
a.tenantMu.Lock()
if existing := a.tenantAdapters[tenantID]; existing != nil {
a.tenantMu.Unlock()
return existing, existing, nil
}
a.tenantAdapters[tenantID] = tenantAdp
a.tenantMu.Unlock()
return tenantAdp, tenantAdp, nil
return tenantAdp
}
func (a *adapter) cacheTenantAdapter(tenantID t.TenantID, tenantAdp *adapter, generation uint64, expiresAt time.Time) {
a.tenantMu.Lock()
defer a.tenantMu.Unlock()
if generation != a.tenantRouteGen || tenantAdp == nil {
return
}
if a.tenantRouteExpiry == nil {
a.tenantRouteExpiry = make(map[t.TenantID]time.Time)
}
a.tenantRouteExpiry[tenantID] = expiresAt
}
// InvalidateTenantRoute forces the next operation to revalidate tenant state.
func (a *adapter) InvalidateTenantRoute(tenantID t.TenantID) {
a.tenantMu.Lock()
defer a.tenantMu.Unlock()
a.tenantRouteGen++
if tenantID.IsValid() {
delete(a.tenantRouteExpiry, tenantID)
return
}
a.tenantRouteExpiry = make(map[t.TenantID]time.Time)
}
func (a *adapter) tenantByID(tenantID t.TenantID) (*t.Tenant, error) {
@@ -4671,7 +4736,7 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
return a.db.GetContext(ctx, &allowed, query, args...)
}
err := run()
for attempt := 0; isRetryableMySQLReadErr(err) && attempt < 2; attempt++ {
for attempt := 0; isRetryableMySQLError(err) && attempt < 2; attempt++ {
time.Sleep(time.Duration(attempt+1) * 25 * time.Millisecond)
err = run()
}
@@ -4813,7 +4878,7 @@ func isIDOutOfRange(err error) bool {
return ok && myerr.Number == 1264 && strings.Contains(strings.ToLower(myerr.Message), "id")
}
func isRetryableMySQLReadErr(err error) bool {
func isRetryableMySQLError(err error) bool {
if err == nil {
return false
}
@@ -0,0 +1,66 @@
//go:build mysql
package mysql
import (
"testing"
"time"
"github.com/tinode/chat/server/store/types"
)
func TestTenantRouteCacheExpiresAndInvalidates(t *testing.T) {
now := time.Now()
tenantID := types.TenantID(8)
tenantAdp := &adapter{tenantID: tenantID}
root := &adapter{
tenantDBRouting: true,
tenantAdapters: map[types.TenantID]*adapter{tenantID: tenantAdp},
tenantRouteExpiry: make(map[types.TenantID]time.Time),
}
root.cacheTenantAdapter(tenantID, tenantAdp, root.tenantRouteGeneration(), now.Add(time.Minute))
if got, ok := root.cachedTenantAdapter(tenantID, now); !ok || got != tenantAdp {
t.Fatalf("cached route = %p, %v; want %p, true", got, ok, tenantAdp)
}
if got, _, err := root.TenantAdapterFor(tenantID); err != nil || got != tenantAdp {
t.Fatalf("TenantAdapterFor cached route = %p, %v; want %p, nil", got, err, tenantAdp)
}
staleGeneration := root.tenantRouteGeneration()
root.InvalidateTenantRoute(tenantID)
if _, ok := root.cachedTenantAdapter(tenantID, now); ok {
t.Fatal("invalidated tenant route remained cached")
}
root.cacheTenantAdapter(tenantID, tenantAdp, staleGeneration, now.Add(time.Minute))
if _, ok := root.cachedTenantAdapter(tenantID, now); ok {
t.Fatal("an in-flight stale refresh repopulated the route cache")
}
root.cacheTenantAdapter(tenantID, tenantAdp, root.tenantRouteGeneration(), now.Add(-time.Second))
if _, ok := root.cachedTenantAdapter(tenantID, now); ok {
t.Fatal("expired tenant route remained cached")
}
}
func TestInvalidateAllTenantRoutes(t *testing.T) {
now := time.Now()
root := &adapter{
tenantDBRouting: true,
tenantAdapters: map[types.TenantID]*adapter{
8: {tenantID: 8},
9: {tenantID: 9},
},
tenantRouteExpiry: map[types.TenantID]time.Time{
8: now.Add(time.Minute),
9: now.Add(time.Minute),
},
}
root.InvalidateTenantRoute(0)
for _, tenantID := range []types.TenantID{8, 9} {
if _, ok := root.cachedTenantAdapter(tenantID, now); ok {
t.Fatalf("tenant %d route remained cached after global invalidation", tenantID)
}
}
}
@@ -17,6 +17,7 @@ func ensureIMAdminGovernanceTables(exec sqlExecutor) error {
request_id VARCHAR(64) NOT NULL DEFAULT '', login_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
logout_at DATETIME(3), metadata JSON, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), INDEX idx_im_user_login_tenant_time(tenant_id, login_at DESC, id DESC),
INDEX idx_im_user_login_session_close(tenant_id, session_id, logout_at),
INDEX idx_im_user_login_user_time(user_id, login_at DESC, id DESC),
INDEX idx_im_user_login_ip_time(ip, login_at DESC, id DESC),
INDEX idx_im_user_login_device_time(device_id(64), login_at DESC),
@@ -53,5 +54,9 @@ func ensureIMAdminGovernanceTables(exec sqlExecutor) error {
return err
}
}
if err := execIgnoreMySQLError(exec,
`ALTER TABLE im_user_login_logs ADD INDEX idx_im_user_login_session_close(tenant_id,session_id,logout_at)`, 1061); err != nil {
return err
}
return nil
}
@@ -13,8 +13,8 @@ func TestEnsureIMAdminGovernanceTablesDefinesLoginAuditTenantScope(t *testing.T)
if err := ensureIMAdminGovernanceTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 3 {
t.Fatalf("created %d admin governance tables, want 3", len(exec.statements))
if len(exec.statements) != 4 {
t.Fatalf("executed %d admin governance schema statements, want 4", len(exec.statements))
}
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, table := range []string{"im_user_login_logs", "im_message_admin_actions", "im_user_ip_whitelists"} {
@@ -38,4 +38,8 @@ func TestEnsureIMAdminGovernanceTablesDefinesLoginAuditTenantScope(t *testing.T)
if end < 0 || !strings.Contains(joined[start:start+end], "tenant_id bigint not null") {
t.Fatal("im_user_login_logs must declare tenant_id")
}
if !strings.Contains(joined, "idx_im_user_login_session_close(tenant_id, session_id, logout_at)") &&
!strings.Contains(joined, "idx_im_user_login_session_close(tenant_id,session_id,logout_at)") {
t.Fatal("im_user_login_logs must index tenant-scoped session close updates")
}
}
+2 -2
View File
@@ -3305,7 +3305,7 @@ func (a *adapter) tenantP2PMessageMutations(ctx context.Context, id t.TenantID,
fmt.Sprintf("FROM %s AS m ", tenantMessagesTable(id))+
"LEFT JOIN im_dellog AS d ON d.tenant_id=m.tenant_id AND d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+
"LEFT JOIN im_message_revisions AS rev ON rev.tenant_id=m.tenant_id AND rev.topic=m.topic AND rev.seqid=m.seqid AND rev.version=m.version-1 "+
"WHERE m.tenant_id=? AND m.topic=? AND d.deletedfor IS NULL AND (m.version>1 OR m.recalled_at IS NOT NULL OR m.deletedat IS NOT NULL) "+
"WHERE m.tenant_id=? AND m.topic=? AND d.deletedfor IS NULL AND (m.version>1 OR m.recalled_at IS NOT NULL) "+
"ORDER BY m.updatedat DESC,m.seqid DESC LIMIT ?",
decodeUID(uid), id, topic, limit+1)
if err != nil {
@@ -3327,7 +3327,7 @@ func (a *adapter) tenantGroupMessageMutations(ctx context.Context, id t.TenantID
fmt.Sprintf("FROM %s AS m ", table)+
"LEFT JOIN im_dellog AS d ON d.tenant_id=m.tenant_id AND d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+
"LEFT JOIN im_message_revisions AS rev ON rev.tenant_id=m.tenant_id AND rev.topic=m.topic AND rev.seqid=m.seqid AND rev.version=m.version-1 "+
"WHERE m.tenant_id=? AND m.topic=? AND d.deletedfor IS NULL AND (m.version>1 OR m.recalled_at IS NOT NULL OR m.deleted_at IS NOT NULL OR m.status='recalled')")
"WHERE m.tenant_id=? AND m.topic=? AND d.deletedfor IS NULL AND (m.version>1 OR m.recalled_at IS NOT NULL OR m.status='recalled')")
args = append(args, decodeUID(uid), id, topic)
}
args = append(args, limit+1)
+12 -1
View File
@@ -93,7 +93,18 @@ func (a *adapter) TenantLoginAuditClose(id t.TenantID, sessionID string, at time
if tenantAdp != a {
return tenantAdp.TenantLoginAuditClose(id, sessionID, at)
}
_, err = a.db.Exec("UPDATE im_user_login_logs SET logout_at=? WHERE tenant_id=? AND session_id=? AND logout_at IS NULL", at, int64(id), sessionID)
return retryMySQLDeadlock(func() error {
_, execErr := a.db.Exec("UPDATE im_user_login_logs SET logout_at=? WHERE tenant_id=? AND session_id=? AND logout_at IS NULL", at, int64(id), sessionID)
return execErr
}, time.Sleep)
}
func retryMySQLDeadlock(operation func() error, sleep func(time.Duration)) error {
err := operation()
for attempt := 1; isRetryableMySQLError(err) && attempt < 4; attempt++ {
sleep(time.Duration(attempt) * 25 * time.Millisecond)
err = operation()
}
return err
}
@@ -3,9 +3,13 @@
package mysql
import (
"errors"
"os"
"strings"
"testing"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
)
func TestLoginAuditQueriesAreTenantScoped(t *testing.T) {
@@ -24,3 +28,48 @@ func TestLoginAuditQueriesAreTenantScoped(t *testing.T) {
}
}
}
func TestRetryMySQLDeadlockEventuallySucceeds(t *testing.T) {
calls := 0
var delays []time.Duration
err := retryMySQLDeadlock(func() error {
calls++
if calls < 3 {
return &mysqldriver.MySQLError{Number: 1213, SQLState: [5]byte{'4', '0', '0', '0', '1'}}
}
return nil
}, func(delay time.Duration) { delays = append(delays, delay) })
if err != nil {
t.Fatal(err)
}
if calls != 3 {
t.Fatalf("operation calls=%d want=3", calls)
}
want := []time.Duration{25 * time.Millisecond, 50 * time.Millisecond}
if len(delays) != len(want) || delays[0] != want[0] || delays[1] != want[1] {
t.Fatalf("retry delays=%v want=%v", delays, want)
}
}
func TestRetryMySQLDeadlockStopsOnNonRetryableError(t *testing.T) {
want := errors.New("invalid update")
calls := 0
err := retryMySQLDeadlock(func() error {
calls++
return want
}, func(time.Duration) { t.Fatal("non-retryable error must not sleep") })
if !errors.Is(err, want) || calls != 1 {
t.Fatalf("err=%v calls=%d want=%v/1", err, calls, want)
}
}
func TestRetryMySQLDeadlockStopsAfterFourAttempts(t *testing.T) {
calls := 0
err := retryMySQLDeadlock(func() error {
calls++
return &mysqldriver.MySQLError{Number: 1205}
}, func(time.Duration) {})
if err == nil || calls != 4 {
t.Fatalf("err=%v calls=%d want non-nil/4", err, calls)
}
}
+5
View File
@@ -16,6 +16,7 @@ import (
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
immetrics "github.com/tinode/chat/server/metrics"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
@@ -247,7 +248,9 @@ func (h *Hub) run() {
if dst.clientMsg != nil {
select {
case dst.clientMsg <- msg:
immetrics.ObserveInternalQueueEnqueue("topic_client", len(dst.clientMsg), cap(dst.clientMsg), true)
default:
immetrics.ObserveInternalQueueEnqueue("topic_client", len(dst.clientMsg), cap(dst.clientMsg), false)
logs.Err.Println("hub: topic's broadcast queue is full", dst.name)
}
} else {
@@ -275,7 +278,9 @@ func (h *Hub) run() {
// Everything is OK, sending packet to known topic
select {
case dst.serverMsg <- msg:
immetrics.ObserveInternalQueueEnqueue("topic_server", len(dst.serverMsg), cap(dst.serverMsg), true)
default:
immetrics.ObserveInternalQueueEnqueue("topic_server", len(dst.serverMsg), cap(dst.serverMsg), false)
logs.Err.Println("hub: topic's broadcast queue is full", dst.name)
}
} else if (strings.HasPrefix(msg.RcptTo, "usr") || strings.HasPrefix(msg.RcptTo, "grp")) &&
+38 -4
View File
@@ -3133,6 +3133,25 @@ func serveIMTopicCalls(wrt http.ResponseWriter, req *http.Request) {
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor})
}
// serveIMCallsByTopic serves GET /v1/im/calls/{topic} — 对接文档契约路径,按会话查通话记录。
func serveIMCallsByTopic(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromCallsByTopicPath(req.URL.Path))
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
items, nextCursor, err := store.Calls.List(principal.TenantID, principal.Uid, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor})
}
func serveIMMessageSearch(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -5794,10 +5813,16 @@ func serveIMGroupMemberProfile(wrt http.ResponseWriter, req *http.Request, topic
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, memberID, principal.Uid.P2PName(memberID))
if err != nil {
writeIMRelationError(wrt, req, err)
return
// 查询本人时 GetRelationshipContext 不接受 userID==peerID(会报 ErrMalformed),
// 直接构造 self 上下文,群内本人资料返回 200。
ctx := &types.RelationshipContext{Relationship: types.RelationshipSelf, CanSend: true}
if memberID != principal.Uid {
var err error
ctx, err = store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, memberID, principal.Uid.P2PName(memberID))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
}
writeIMSuccess(wrt, req, groupMemberProfilePayload(topic, memberSub, user, ctx))
}
@@ -11006,6 +11031,15 @@ func topicFromCallsPath(path string) string {
return topicFromPathWithSuffix(path, prefix, suffix)
}
func topicFromCallsByTopicPath(path string) string {
const prefix = "/v1/im/calls/"
idx := strings.Index(path, prefix)
if idx < 0 {
return ""
}
return strings.Trim(strings.TrimSpace(path[idx+len(prefix):]), "/")
}
func topicFromDraftPath(path string) string {
const prefix = "/v1/im/topics/"
const suffix = "/draft"
+6 -4
View File
@@ -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()
}
+15
View File
@@ -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)
}
}
+23 -6
View File
@@ -53,7 +53,9 @@ func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *ty
msg.ClientMsgID = clientMsgID
fromUid := types.ParseUid(msg.From)
if clientMsgID != "" {
started := time.Now()
claim, claimed, runtimeReady, claimErr := store.ClaimMessageUnique(tenantID, msg.Topic, fromUid, clientMsgID)
immetrics.ObserveMessagePublishStage(messageKind(msg.Topic), "redis_idempotency", started, claimErr)
if claimErr != nil {
return nil, claimErr
}
@@ -69,12 +71,16 @@ func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *ty
}
}
candidateMonth := msg.CreatedAt.UTC().Format("200601")
if seq, shardMonth, runtimeReady, seqErr := store.ReserveMessageSeq(tenantID, msg.Topic, msg.SeqId-1, candidateMonth); seqErr != nil {
seqStarted := time.Now()
seq, shardMonth, runtimeReady, seqErr := store.ReserveMessageSeq(tenantID, msg.Topic, msg.SeqId-1, candidateMonth)
immetrics.ObserveMessagePublishStage(messageKind(msg.Topic), "redis_sequence", seqStarted, seqErr)
if seqErr != nil {
if clientMsgID != "" {
_ = store.MarkMessageUniqueFailed(tenantID, msg.Topic, fromUid, clientMsgID)
}
return nil, seqErr
} else if runtimeReady {
}
if runtimeReady {
msg.SeqId = seq
if isGroupMessage {
msg.ShardMonth = shardMonth
@@ -83,7 +89,9 @@ func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *ty
if msg.ShardMonth == "" && isGroupMessage {
msg.ShardMonth = candidateMonth
}
kafkaStarted := time.Now()
if err = enqueueKafkaMessagePersist(ctx, tenantID, msg, attachmentURLs, readBySender); err != nil {
immetrics.ObserveMessagePublishStage(messageKind(msg.Topic), "kafka_produce", kafkaStarted, err)
if clientMsgID != "" {
if markErr := store.MarkMessageUniquePending(tenantID, msg.Topic, fromUid, clientMsgID, msg.SeqId); markErr != nil {
logs.Warn.Printf("kafka message persist: failed to mark message unique key pending topic=%s seq=%d: %v", msg.Topic, msg.SeqId, markErr)
@@ -92,6 +100,7 @@ func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *ty
logs.Warn.Printf("kafka message persist: publish state unknown topic=%s seq=%d client_mid=%s err=%v", msg.Topic, msg.SeqId, clientMsgID, err)
return &types.MessageSaveResult{SeqId: msg.SeqId, Pending: true, MarkedReadBySender: readBySender && !fromUid.IsZero()}, err
}
immetrics.ObserveMessagePublishStage(messageKind(msg.Topic), "kafka_produce", kafkaStarted, nil)
if clientMsgID != "" {
if err = store.MarkMessageUniquePending(tenantID, msg.Topic, fromUid, clientMsgID, msg.SeqId); err != nil {
logs.Warn.Printf("kafka message persist: failed to mark message unique key pending topic=%s seq=%d: %v", msg.Topic, msg.SeqId, err)
@@ -105,11 +114,19 @@ func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *ty
return &types.MessageSaveResult{SeqId: msg.SeqId, Pending: true, MarkedReadBySender: readBySender && !fromUid.IsZero()}, nil
}
func enqueueKafkaMessagePersist(ctx context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) error {
kind := "p2p"
if types.GetTopicCat(msg.Topic) == types.TopicCatGrp {
kind = "group"
func messageKind(topic string) string {
switch types.GetTopicCat(topic) {
case types.TopicCatGrp:
return "group"
case types.TopicCatP2P:
return "p2p"
default:
return "other"
}
}
func enqueueKafkaMessagePersist(ctx context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) error {
kind := messageKind(msg.Topic)
payload, err := json.Marshal(kafkaMessagePersistPayload{
Message: *msg,
AttachmentURLs: attachmentURLs,
+6
View File
@@ -182,6 +182,8 @@ var globals struct {
tlsRedirectHTTP string
// Maximum message size allowed from peer.
maxMessageSize int64
// Log complete client packets. Disabled by default because it is expensive on the publish hot path.
logClientPackets bool
// Maximum number of group topic subscribers.
maxSubscriberCount int
// Maximum number of indexable tags.
@@ -335,6 +337,8 @@ type configType struct {
// Maximum message size allowed from client. Intended to prevent malicious client from sending
// very large files inband (does not affect out of band uploads).
MaxMessageSize int `json:"max_message_size"`
// Log every decoded client packet. Intended only for short diagnostic sessions.
LogClientPackets bool `json:"log_client_packets"`
// Maximum number of group topic subscribers.
MaxSubscriberCount int `json:"max_subscriber_count"`
// Masked tags namespaces: tags immutable on User (mask), mutable on Topic only within the mask.
@@ -670,6 +674,7 @@ func main() {
if globals.maxMessageSize <= 0 {
globals.maxMessageSize = defaultMaxMessageSize
}
globals.logClientPackets = config.LogClientPackets
// Maximum number of group topic subscribers
globals.maxSubscriberCount = config.MaxSubscriberCount
if globals.maxSubscriberCount <= 1 {
@@ -929,6 +934,7 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/favorites", serveIMFavorites)
mux.HandleFunc(config.ApiPath+"v1/im/favorites/", serveIMFavoriteResource)
mux.HandleFunc(config.ApiPath+"v1/im/calls/rtc-join", serveIMCallRTCJoin)
mux.HandleFunc(config.ApiPath+"v1/im/calls/", serveIMCallsByTopic)
mux.HandleFunc(config.ApiPath+"v1/im/calls", serveIMCalls)
mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction)
mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext)
+5 -2
View File
@@ -10,6 +10,7 @@
"api_key_salt": "T713/rYYgW7g4m3vG6zGRh7+FM1t0T8j13koXScOAj4=",
"max_message_size": 131072,
"log_client_packets": false,
"max_subscriber_count": 128,
"max_tag_count": 16,
"permanent_accounts": false,
@@ -84,8 +85,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
}
+80
View File
@@ -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)
}
+47
View File
@@ -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)
}
}
}
+42 -1
View File
@@ -102,11 +102,29 @@ var (
Name: "im_cluster_proxy_response_total",
Help: "Cluster proxy responses by pod and result.",
}, []string{"pod", "result"})
InternalQueueEnqueueTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "im_internal_queue_enqueue_total",
Help: "Internal message queue enqueue attempts by pod, queue, and result.",
}, []string{"pod", "queue", "result"})
InternalQueueDepth = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "im_internal_queue_depth",
Help: "Internal message queue depth observed after enqueue attempts by pod and queue.",
Buckets: []float64{0, 1, 4, 16, 32, 64, 128, 192, 256, 512, 1024, 2048, 4096, 8192},
}, []string{"pod", "queue"})
InternalQueueCapacity = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "im_internal_queue_capacity",
Help: "Configured internal message queue capacity by pod and queue.",
}, []string{"pod", "queue"})
MessagePersistDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "im_message_persist_duration_seconds",
Help: "Kafka message persistence duration by message kind and result.",
Buckets: []float64{0.001, 0.003, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
}, []string{"kind", "result"})
MessagePublishStageDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "im_message_publish_stage_duration_seconds",
Help: "Message publish hot-path duration by message kind, stage, and result.",
Buckets: []float64{0.001, 0.003, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
}, []string{"kind", "stage", "result"})
KafkaMessagePersistTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "im_kafka_message_persist_total",
Help: "Kafka message persistence outcomes by message kind and result.",
@@ -130,7 +148,9 @@ func init() {
HTTPRequestsTotal, HTTPRequestDuration, DBQueryDuration,
RealtimeFanoutEventsTotal, RealtimeFanoutDeliveryDuration, ClusterP2MQueueLength,
ClusterP2MQueueCapacity, ClusterP2MEnqueueTotal, ClusterRPCInflight, ClusterRPCDuration,
ClusterRouteTotal, ClusterProxyResponseTotal, MessagePersistDuration, KafkaMessagePersistTotal)
ClusterRouteTotal, ClusterProxyResponseTotal, InternalQueueEnqueueTotal, InternalQueueDepth,
InternalQueueCapacity, MessagePersistDuration, MessagePublishStageDuration,
KafkaMessagePersistTotal)
Registry.MustRegister(KafkaConsumerLag, KafkaBacklogOldestAge, KafkaDeadLettersTotal)
}
@@ -149,6 +169,15 @@ func ObserveDB(driverName, operation string, started time.Time, err error) {
DBQueryDuration.WithLabelValues(driverName, operation, status).Observe(time.Since(started).Seconds())
}
func ObserveMessagePublishStage(kind, stage string, started time.Time, err error) {
status := "ok"
if err != nil {
status = "error"
}
MessagePublishStageDuration.WithLabelValues(normalizeLabel(kind), normalizeLabel(stage), status).
Observe(time.Since(started).Seconds())
}
func IncRealtimeFanout(stage, kind, targetType, status string) {
RealtimeFanoutEventsTotal.WithLabelValues(normalizeLabel(stage), normalizeLabel(kind),
normalizeLabel(targetType), normalizeLabel(status)).Inc()
@@ -162,6 +191,18 @@ func ObserveRealtimeFanoutDelivery(kind, targetType string, timestamp time.Time)
Observe(time.Since(timestamp).Seconds())
}
func ObserveInternalQueueEnqueue(queue string, depth, capacity int, accepted bool) {
result := "ok"
if !accepted {
result = "full"
}
pod := metricPodName()
queue = normalizeLabel(queue)
InternalQueueEnqueueTotal.WithLabelValues(pod, queue, result).Inc()
InternalQueueDepth.WithLabelValues(pod, queue).Observe(float64(depth))
InternalQueueCapacity.WithLabelValues(pod, queue).Set(float64(capacity))
}
func normalizeLabel(value string) string {
if value == "" {
return "unknown"
+2
View File
@@ -90,6 +90,7 @@ func TestServeMetricsIncludesWSConnections(t *testing.T) {
immetrics.KafkaMessagePersistTotal.WithLabelValues("p2p", "batch_ok").Add(0)
immetrics.KafkaConsumerLag.WithLabelValues("test", "topic", "group").Set(0)
immetrics.KafkaDeadLettersTotal.WithLabelValues("p2p", "test").Add(0)
immetrics.ObserveInternalQueueEnqueue("test", 0, 1, true)
wrt := httptest.NewRecorder()
serveMetrics(wrt, httptest.NewRequest(http.MethodGet, "/metrics", nil))
@@ -102,6 +103,7 @@ func TestServeMetricsIncludesWSConnections(t *testing.T) {
}
for _, metric := range []string{"im_ws_connections_active", "im_cluster_p2m_queue_length",
"im_cluster_p2m_enqueue_total", "im_cluster_rpc_duration_seconds", "im_cluster_route_total",
"im_internal_queue_enqueue_total", "im_internal_queue_depth", "im_internal_queue_capacity",
"im_kafka_message_persist_total", "im_kafka_consumer_lag", "im_kafka_dead_letters_total"} {
if !strings.Contains(body, "# HELP "+metric+" ") {
t.Fatalf("metric %s was not exported", metric)
+24 -3
View File
@@ -5,6 +5,7 @@ import (
"strings"
"github.com/tinode/chat/server/logs"
immetrics "github.com/tinode/chat/server/metrics"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
@@ -466,12 +467,26 @@ func (t *Topic) presSubsOffline(what string, params *presParams,
if offlineOnly {
skipTopic = t.name
}
if t.cat == types.TopicCatGrp && offlineOnly && what == "msg" && store.RealtimeFanoutEnabled() {
recipients := make([]realtimeGroupUnreadRecipient, 0, len(t.perUser))
for uid, pud := range t.perUser {
if pud.deleted || !presOfflineFilter(pud.modeGiven&pud.modeWant, what, filterSource) {
continue
}
recipients = append(recipients, realtimeGroupUnreadRecipient{
User: uid.UserId(),
Source: t.original(uid),
})
}
if publishRealtimeGroupUnread(t.tenantID, t.name, params.actor, params.seqID, recipients) {
return
}
}
for uid, pud := range t.perUser {
if pud.deleted || !presOfflineFilter(pud.modeGiven&pud.modeWant, what, filterSource) {
continue
}
user := uid.UserId()
actor := params.actor
target := params.target
@@ -693,10 +708,16 @@ func presSingleUserOfflineOffline(tenantID types.TenantID, uid types.Uid, origin
}
func routeUserServerMessage(uid types.Uid, msg *ServerComMessage) {
if msg == nil || uid.IsZero() {
if msg == nil || uid.IsZero() || globals.hub == nil {
return
}
globals.hub.routeSrv <- msg
select {
case globals.hub.routeSrv <- msg:
immetrics.ObserveInternalQueueEnqueue("hub_route_server", len(globals.hub.routeSrv), cap(globals.hub.routeSrv), true)
default:
immetrics.ObserveInternalQueueEnqueue("hub_route_server", len(globals.hub.routeSrv), cap(globals.hub.routeSrv), false)
logs.Err.Printf("route user message: hub routeSrv queue full, user=%s", uid.UserId())
}
publishRealtimeUserMessage(uid, msg)
}
+98
View File
@@ -16,6 +16,7 @@ import (
const (
realtimeKindMessage = "message"
realtimeKindGroupUnread = "group_unread"
realtimeKindSessionControl = "session_control"
realtimeKindCallEvent = "call_event"
@@ -23,8 +24,21 @@ const (
realtimeTargetUser = "user"
realtimeTargetDevice = "device"
realtimeTargetPlatf = "platform"
realtimeTargetUsers = "users"
)
type realtimeGroupUnreadRecipient struct {
User string `json:"user"`
Source string `json:"source"`
}
type realtimeGroupUnreadMessage struct {
Topic string `json:"topic"`
Actor string `json:"actor,omitempty"`
SeqID int64 `json:"seq_id"`
Recipients []realtimeGroupUnreadRecipient `json:"recipients"`
}
type realtimeCallEventMessage struct {
Topic string `json:"topic"`
From string `json:"from"`
@@ -68,6 +82,12 @@ func publishRealtimeUserMessage(uid types.Uid, msg *ServerComMessage) {
immetrics.IncRealtimeFanout("publish", realtimeKindMessage, realtimeTargetUser, "skip_invalid")
return
}
// Native cluster routing already forwards user topics between pods. Publishing
// the same message through Redis duplicates work and may deliver it twice.
if globals.cluster != nil {
immetrics.IncRealtimeFanout("publish", realtimeKindMessage, realtimeTargetUser, "skip_native_cluster")
return
}
dedupKey := realtimeMessageDedupKey(uid.UserId(), msg)
if err := store.PublishRealtimeFanout(currentNodeID(), msg.TenantID, realtimeKindMessage, realtimeTargetUser, uid.UserId(), msg.SkipSid, dedupKey, msg); err != nil {
immetrics.IncRealtimeFanout("publish", realtimeKindMessage, realtimeTargetUser, "error")
@@ -77,6 +97,29 @@ func publishRealtimeUserMessage(uid types.Uid, msg *ServerComMessage) {
immetrics.IncRealtimeFanout("publish", realtimeKindMessage, realtimeTargetUser, "ok")
}
func publishRealtimeGroupUnread(tenantID types.TenantID, topic, actor string, seqID int64,
recipients []realtimeGroupUnreadRecipient) bool {
if !tenantID.IsValid() || topic == "" || seqID <= 0 || len(recipients) == 0 || !store.RealtimeFanoutEnabled() {
return false
}
payload := &realtimeGroupUnreadMessage{
Topic: topic,
Actor: actor,
SeqID: seqID,
Recipients: recipients,
}
dedupKey := "group-unread:" + topic + ":" + strconv.FormatInt(seqID, 10)
if err := store.PublishRealtimeFanout(currentNodeID(), tenantID, realtimeKindGroupUnread,
realtimeTargetUsers, topic, "", dedupKey, payload); err != nil {
immetrics.IncRealtimeFanout("publish", realtimeKindGroupUnread, realtimeTargetUsers, "error")
logs.Warn.Printf("redis realtime fanout: publish group unread topic=%s seq=%d failed: %v", topic, seqID, err)
return false
}
immetrics.IncRealtimeFanout("publish", realtimeKindGroupUnread, realtimeTargetUsers, "ok")
deliverRealtimeGroupUnread(tenantID, payload)
return true
}
func publishRealtimeCallEvent(msg *ClientComMessage) {
if msg == nil || msg.Note == nil || !msg.TenantID.IsValid() ||
msg.RcptTo == "" || msg.AsUser == "" || msg.Note.What != "call" ||
@@ -193,6 +236,16 @@ func deliverRealtimeFanoutEnvelope(envelope *store.RealtimeFanoutEnvelope) {
return
}
immetrics.IncRealtimeFanout("receive", envelope.Kind, envelope.TargetType, "ok")
if envelope.Kind == realtimeKindGroupUnread {
var batch realtimeGroupUnreadMessage
if err := json.Unmarshal(envelope.Message, &batch); err != nil {
immetrics.IncRealtimeFanout("receive", envelope.Kind, envelope.TargetType, "malformed")
logs.Warn.Println("redis realtime fanout: malformed group unread batch", err)
return
}
deliverRealtimeGroupUnread(envelope.TenantID, &batch)
return
}
var msg ServerComMessage
if err := json.Unmarshal(envelope.Message, &msg); err != nil {
immetrics.IncRealtimeFanout("receive", envelope.Kind, envelope.TargetType, "malformed")
@@ -224,6 +277,51 @@ func deliverRealtimeFanoutEnvelope(envelope *store.RealtimeFanoutEnvelope) {
}
}
func deliverRealtimeGroupUnread(tenantID types.TenantID, batch *realtimeGroupUnreadMessage) {
if globals.sessionStore == nil || batch == nil || batch.Topic == "" || batch.SeqID <= 0 || len(batch.Recipients) == 0 {
immetrics.IncRealtimeFanout("deliver", realtimeKindGroupUnread, realtimeTargetUsers, "skip_invalid")
return
}
recipients := make(map[types.Uid]string, len(batch.Recipients))
for _, recipient := range batch.Recipients {
uid := types.ParseUserId(recipient.User)
if !uid.IsZero() {
recipients[uid] = recipient.Source
}
}
delivered := 0
globals.sessionStore.Range(func(_ string, sess *Session) bool {
if sess == nil || sess.isCluster() || sess.isProxy() || sess.tenantID != tenantID {
return true
}
source, ok := recipients[sess.uid]
if !ok || sessionHasTopicOrP2PAlias(sess, batch.Topic) {
return true
}
actor := batch.Actor
if actor == sess.uid.UserId() {
actor = ""
}
msg := &ServerComMessage{
TenantID: tenantID,
RcptTo: sess.uid.UserId(),
Pres: &MsgServerPres{
Topic: "me",
What: "msg",
Src: source,
AcsActor: actor,
SeqId: batch.SeqID,
SkipTopic: batch.Topic,
},
}
if sess.queueOut(msg) {
delivered++
}
return true
})
recordRealtimeFanoutDeliverResult(realtimeKindGroupUnread, realtimeTargetUsers, delivered)
}
func deliverRealtimeCallEvent(envelope *store.RealtimeFanoutEnvelope) {
var event realtimeCallEventMessage
if err := json.Unmarshal(envelope.Message, &event); err != nil {
+54
View File
@@ -109,6 +109,60 @@ func TestRealtimeUserPresenceSkipsSessionsAttachedToTopic(t *testing.T) {
}
}
func TestRealtimeGroupUnreadBatchPreservesDetachedMultiDeviceNotification(t *testing.T) {
oldStore := globals.sessionStore
defer func() { globals.sessionStore = oldStore }()
tenantID := types.TenantID(1)
recipient := types.Uid(1002)
other := types.Uid(1003)
topic := "grpUnreadBatchTest"
globals.sessionStore = realtimeTestSessionStore(
realtimeTestSession("sid-attached", tenantID, recipient, topic),
realtimeTestSession("sid-detached-1", tenantID, recipient, ""),
realtimeTestSession("sid-detached-2", tenantID, recipient, ""),
realtimeTestSession("sid-other", tenantID, other, ""),
)
payload := realtimeGroupUnreadMessage{
Topic: topic,
Actor: types.Uid(1001).UserId(),
SeqID: 51,
Recipients: []realtimeGroupUnreadRecipient{
{User: recipient.UserId(), Source: topic},
},
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
deliverRealtimeFanoutEnvelope(&store.RealtimeFanoutEnvelope{
TenantID: tenantID,
Kind: realtimeKindGroupUnread,
TargetType: realtimeTargetUsers,
Target: topic,
Message: raw,
})
if len(globals.sessionStore.Get("sid-attached").send) != 0 {
t.Fatal("attached session must rely on the topic data broadcast")
}
for _, sid := range []string{"sid-detached-1", "sid-detached-2"} {
select {
case got := <-globals.sessionStore.Get(sid).send:
pres := got.(*ServerComMessage).Pres
if pres == nil || pres.What != "msg" || pres.Src != topic || pres.SeqId != 51 || pres.SkipTopic != topic {
t.Fatalf("%s got unexpected presence: %#v", sid, pres)
}
default:
t.Fatalf("detached session %s must receive unread presence", sid)
}
}
if len(globals.sessionStore.Get("sid-other").send) != 0 {
t.Fatal("non-recipient session received unread presence")
}
}
func TestRealtimeP2PTopicFallbackRewritesVisibleTopic(t *testing.T) {
oldStore := globals.sessionStore
oldHub := globals.hub
+11 -6
View File
@@ -25,6 +25,7 @@ import (
"github.com/tinode/chat/pbx"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
immetrics "github.com/tinode/chat/server/metrics"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
@@ -519,13 +520,15 @@ func (s *Session) dispatchRaw(raw []byte) {
s.queueOut(ErrMalformed("", "", now))
return
}
toLog := redactClientMessageForLog(raw)
truncated := ""
if len(toLog) > 512 {
toLog = toLog[:512]
truncated = "<...>"
if globals.logClientPackets {
toLog := redactClientMessageForLog(raw)
truncated := ""
if len(toLog) > 512 {
toLog = toLog[:512]
truncated = "<...>"
}
logs.Info.Printf("in: '%s%s' sid='%s' uid='%s'", toLog, truncated, s.sid, s.uid)
}
logs.Info.Printf("in: '%s%s' sid='%s' uid='%s'", toLog, truncated, s.sid, s.uid)
s.dispatch(&msg)
}
@@ -858,7 +861,9 @@ func (s *Session) publish(msg *ClientComMessage) {
// This is a post to a subscribed topic. The message is sent to the topic only
select {
case sub.broadcast <- msg:
immetrics.ObserveInternalQueueEnqueue("session_topic_publish", len(sub.broadcast), cap(sub.broadcast), true)
default:
immetrics.ObserveInternalQueueEnqueue("session_topic_publish", len(sub.broadcast), cap(sub.broadcast), false)
// Reply with a 503 to the user.
s.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))
logs.Err.Println("s.publish: sub.broadcast channel full, topic ", msg.RcptTo, s.sid)
+4
View File
@@ -7,6 +7,7 @@ import (
"sync"
"time"
adapter "github.com/tinode/chat/server/db"
"github.com/tinode/chat/server/store/types"
)
@@ -152,6 +153,9 @@ func InvalidateTenantConfigCache(code string, id types.TenantID) {
tenantByIDCache.clear()
realNamePolicyCache.clear()
}
if invalidator, ok := adp.(adapter.TenantRouteCacheInvalidator); ok {
invalidator.InvalidateTenantRoute(id)
}
}
// InvalidateRegistrationConfigCache clears cached global registration config.
+7
View File
@@ -392,6 +392,13 @@ func StartRealtimeFanoutSubscriber(ctx context.Context, nodeID string, handler f
return groupMsgRuntime.StartRealtimeFanoutSubscriber(ctx, nodeID, handler)
}
// RealtimeFanoutEnabled reports whether Redis Pub/Sub fanout is configured.
// Callers use it to retain the native routing fallback when Redis is disabled.
func RealtimeFanoutEnabled() bool {
runtime, ok := groupMsgRuntime.(*redisGroupMessageRuntime)
return ok && runtime != nil && runtime.cfg.GroupFanout
}
func CacheObject(tenantID types.TenantID, namespace, id string, value any) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || value == nil {
return nil
+37 -4
View File
@@ -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()
+42
View File
@@ -0,0 +1,42 @@
package store
import (
"testing"
"github.com/tinode/chat/server/store/types"
)
func TestAggregatePreparedMessageBatchUpdatesKeepsLatestSeq(t *testing.T) {
user1 := types.Uid(1)
user2 := types.Uid(2)
items := []PreparedMessageBatchItem{
{Message: &types.Message{Topic: "p2p-b", From: user1.String(), SeqId: 3}, ReadBySender: true},
{Message: &types.Message{Topic: "p2p-a", From: user1.String(), SeqId: 2}, ReadBySender: true},
{Message: &types.Message{Topic: "p2p-a", From: user2.String(), SeqId: 5}, ReadBySender: true},
{Message: &types.Message{Topic: "p2p-a", From: user1.String(), SeqId: 6}, ReadBySender: true},
{Message: &types.Message{Topic: "p2p-b", From: user1.String(), SeqId: 7}, ReadBySender: false},
{},
}
topics, reads := aggregatePreparedMessageBatchUpdates(items)
if len(topics) != 2 || topics[0].topic != "p2p-a" || topics[0].message.SeqId != 6 ||
topics[1].topic != "p2p-b" || topics[1].message.SeqId != 7 {
t.Fatalf("unexpected topic updates: %+v", topics)
}
if len(reads) != 3 {
t.Fatalf("read updates=%d want=3: %+v", len(reads), reads)
}
got := make(map[string]int64, len(reads))
for _, update := range reads {
got[update.topic+":"+update.from.String()] = update.message.SeqId
}
for key, want := range map[string]int64{
"p2p-a:" + user1.String(): 6,
"p2p-a:" + user2.String(): 5,
"p2p-b:" + user1.String(): 3,
} {
if got[key] != want {
t.Fatalf("read update %s=%d want=%d; all=%v", key, got[key], want, got)
}
}
}
+95 -14
View File
@@ -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
}
@@ -3347,7 +3373,7 @@ func relationshipContext(tenantAdp adapter.TenantBusinessAdapter, tenantID types
if friendship, err := tenantAdp.TenantFriendshipGet(tenantID, userID, peerID); err != nil {
return nil, err
} else if friendship != nil && friendship.Status == types.RelationshipFriend {
cacheFriendOneWay(tenantID, userID, peerID)
cacheFriendPair(tenantID, userID, peerID)
ctx.Relationship = types.RelationshipFriend
ctx.CanSend = true
ctx.Remark = friendship.Remark
@@ -3682,6 +3708,58 @@ type PreparedMessageBatchItem struct {
ReadBySender bool
}
type preparedMessageBatchTopicUpdate struct {
topic string
message *types.Message
}
type preparedMessageBatchReadUpdate struct {
topic string
from types.Uid
message *types.Message
}
func aggregatePreparedMessageBatchUpdates(items []PreparedMessageBatchItem) ([]preparedMessageBatchTopicUpdate, []preparedMessageBatchReadUpdate) {
topics := make(map[string]*types.Message)
reads := make(map[string]preparedMessageBatchReadUpdate)
for _, item := range items {
msg := item.Message
if msg == nil {
continue
}
if previous := topics[msg.Topic]; previous == nil || msg.SeqId > previous.SeqId {
topics[msg.Topic] = msg
}
from := types.ParseUid(msg.From)
if !item.ReadBySender || from.IsZero() {
continue
}
key := msg.Topic + "\x00" + from.String()
if previous, ok := reads[key]; !ok || msg.SeqId > previous.message.SeqId {
reads[key] = preparedMessageBatchReadUpdate{topic: msg.Topic, from: from, message: msg}
}
}
topicKeys := make([]string, 0, len(topics))
for topic := range topics {
topicKeys = append(topicKeys, topic)
}
sort.Strings(topicKeys)
topicUpdates := make([]preparedMessageBatchTopicUpdate, 0, len(topicKeys))
for _, topic := range topicKeys {
topicUpdates = append(topicUpdates, preparedMessageBatchTopicUpdate{topic: topic, message: topics[topic]})
}
readKeys := make([]string, 0, len(reads))
for key := range reads {
readKeys = append(readKeys, key)
}
sort.Strings(readKeys)
readUpdates := make([]preparedMessageBatchReadUpdate, 0, len(readKeys))
for _, key := range readKeys {
readUpdates = append(readUpdates, reads[key])
}
return topicUpdates, readUpdates
}
func PersistPreparedMessageBatch(tenantID types.TenantID, items []PreparedMessageBatchItem) error {
if len(items) == 0 {
return nil
@@ -3725,14 +3803,17 @@ func PersistPreparedMessageBatch(tenantID types.TenantID, items []PreparedMessag
if err != nil {
return err
}
for _, item := range items {
msg := item.Message
from := types.ParseUid(msg.From)
if err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, msg.Topic, msg); err != nil {
topicUpdates, readUpdates := aggregatePreparedMessageBatchUpdates(items)
for _, update := range topicUpdates {
if err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, update.topic, update.message); err != nil {
return err
}
invalidateTopicCache(tenantID, msg.Topic)
if isGroup && groupMsgRuntime != nil {
invalidateTopicCache(tenantID, update.topic)
}
if isGroup && groupMsgRuntime != nil {
for _, item := range items {
msg := item.Message
from := types.ParseUid(msg.From)
if msg.ClientMsgID != "" {
_ = groupMsgRuntime.MarkDone(tenantID, msg.Topic, from, msg.ClientMsgID, msg.SeqId, msg.Uid().String())
}
@@ -3741,13 +3822,13 @@ func PersistPreparedMessageBatch(tenantID types.TenantID, items []PreparedMessag
})
_ = groupMsgRuntime.CacheHotMessage(tenantID, msg)
}
if item.ReadBySender && !from.IsZero() {
if updateErr := tenantAdp.TenantSubsUpdate(tenantID, msg.Topic, from, map[string]any{
"RecvSeqId": msg.SeqId, "ReadSeqId": msg.SeqId,
}); updateErr == nil {
invalidateSubscriptionCache(tenantID, msg.Topic, from)
DeleteCachedUserUnreadCount(tenantID, from)
}
}
for _, update := range readUpdates {
if updateErr := tenantAdp.TenantSubsUpdate(tenantID, update.topic, update.from, map[string]any{
"RecvSeqId": update.message.SeqId, "ReadSeqId": update.message.SeqId,
}); updateErr == nil {
invalidateSubscriptionCache(tenantID, update.topic, update.from)
DeleteCachedUserUnreadCount(tenantID, update.from)
}
}
return nil
+1
View File
@@ -9,6 +9,7 @@ const (
FriendRequestExpired = "expired"
RelationshipNone = "none"
RelationshipSelf = "self"
RelationshipFriend = "friend"
RelationshipPendingInbound = "pending_inbound"
RelationshipPendingOutbound = "pending_outbound"
+4 -2
View File
@@ -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
}
+11
View File
@@ -20,6 +20,7 @@ import (
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
immetrics "github.com/tinode/chat/server/metrics"
"github.com/tinode/chat/server/moderation"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
@@ -1362,7 +1363,9 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
store.Relations != nil && store.Store != nil && store.Store.IsOpen() {
peer := t.p2pOtherUser(asUid)
if !peer.IsZero() {
started := time.Now()
canSend, _, err := store.Relations.CanSendP2P(t.tenantID, asUid, peer)
immetrics.ObserveMessagePublishStage("p2p", "relationship_check", started, err)
if err != nil {
logs.Warn.Printf("topic[%s]: failed to check p2p relationship: %v", t.name, err)
msg.sess.queueOut(ErrUnknown(msg.Id, t.original(asUid), msg.Timestamp))
@@ -1375,6 +1378,7 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
}
}
persistStarted := time.Now()
saveResult, err := saveMessageMaybeKafka(context.Background(), t.tenantID,
&types.Message{
ObjHeader: types.ObjHeader{CreatedAt: msg.Timestamp},
@@ -1385,6 +1389,13 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
Head: head,
Content: content,
}, attachments, (pud.modeGiven & pud.modeWant).IsReader())
kind := "other"
if t.cat == types.TopicCatP2P {
kind = "p2p"
} else if t.cat == types.TopicCatGrp {
kind = "group"
}
immetrics.ObserveMessagePublishStage(kind, "save", persistStarted, err)
if err != nil {
logs.Warn.Printf("topic[%s]: failed to save message: %v", t.name, err)
msg.sess.queueOut(ErrUnknown(msg.Id, t.original(asUid), msg.Timestamp))
+2
View File
@@ -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
}