客户端连接租约过期回收与历史读取上限
客户端连接租约过期回收与历史读取上限
- 标题:Client 连接租约按过期时间回收,详情默认只显示未过期租约
- 目标分支:
main - 相关记录:实施计划
摘要
起因是一次运维提问:「客户端详情中 ws,是不是有很多已经下线的」。答案是是,而且行数只增不减。
根因链条(每条都在改动前的代码里可验证):租约只有两条删除路径——客户端正常关闭 WebSocket 时按
connection_id + connection_epoch 释放,以及管理员显式「关闭连接」;kill -9、宿主掉电、网络硬中断
都走不到前者,而 ws_client.go 对释放错误本就是显式忽略的。全仓没有一句按 expires_at 删除的语句,
idx_client_connection_leases_expires 建了但没有消费者;读取侧 ListByInstance 既不过滤也没有行数
上限,于是崩溃过的客户端把死行永久留在表里,抽屉把它们全部列成「已过期」的连接。
本次按三层修复,每层可独立验证:
- 存储层:
clientConnectionRepo新增DeleteExpired(ctx, at, limit)(先查 id、再按主键IN (...)删除,命中既有expires_at索引),ListByInstance/ListByInstances增加liveOnly与limit参数;liveOnly的判定收敛到唯一的clientConnectionLiveCondition,「到界即过期」的<=取法与既有MarkExpired、PurgeUnreported一致。 - 服务层:新增
internal/server/client_connection_sweeper.go,每 1 分钟回收「过期超过 24 小时」的租约, 每轮最多 1000 行,启动时先立即跑一轮;与既有 sweeper 一起在internal/server/runtime.go启停,错误经errors.Join透出,并新增tunnelmesh_client_connection_lease_sweep_total{result,error_class}。 删除语句以时间戳为边界且幂等,因此集群每个节点各跑一份是安全的,不引入分布式锁。 - 接口与前端:
GET /api/v1/clients/{id}/connections支持state=live|all(默认live)与limit(默认 50、上限 200),响应新增truncated(多取一行判定,不额外发COUNT);详情视图与列表页批量 读取改为只取 live 行。后台抽屉加「显示历史租约」开关与被截断时的「加载更多历史」。
无 DDL 变更、无 SchemaVersion 变更、无新增配置项:回收的间隔、保留窗口与批量都是编译期常量,与该文件里
其他不可配置的清扫器常量保持一致。
用户影响
- 客户端详情的「WS 连接」默认只列未过期租约;意外退出的客户端不再堆积「已过期」行。
- 需要排障时勾选「显示历史租约」,可见 24 小时保留窗口内的过期行,默认 50 条、可翻倍到 200 条,超出时列表 上方提示「仅显示最近 N 条租约历史」。
- 名称、租约状态列(活跃/已过期)、过期行活跃流显示
—的规则与「关闭连接」入口的可见性全部保持原样, 历史视图下这些标记正是它的用途。 - Server 启动后第一轮清扫即开始收敛遗留死行,按每分钟 1000 行的速度下降(10 万行约 100 分钟),无需手工
DELETE。 - 统计卡片与
activeConnections/activeStreams的口径不变(本来就只累加未过期租约),但读取量从「该页所有 Client 的全部租约历史」降为「全部有效租约」。 - 连接读取失败不再把整个详情抽屉折叠成错误态,而是显示既有的「连接加载失败」提示条,metadata 与本地监听 入口仍可读。
API / Schema / 配置影响
GET /api/v1/clients/{clientInstanceId}/connections新增state、limit查询参数与data.truncated字段;docs/api/openapi.yaml同步。未传参数的旧调用不会得到更多行,而是得到更少的行(这正是本次的修复 目标),响应字段向后兼容。- 非法
state与无法解析的limit返回 422,响应体沿用本仓库列表参数惯用法{"error":..., "allowed":[...]};limit为数字但越界时按既有pageArgs/queryLimit的做法静默钳制。 internal/storage.ClientConnectionRepository接口新增DeleteExpired,ListByInstance/ListByInstances签名增加liveOnly bool, limit int。生产调用点只有internal/server/client_api.go与 runtime 接线。- 新增指标
tunnelmesh_client_connection_lease_sweep_total{result,error_class};error_class复用既有归一化 取值,不新增标签基数。 - 新增 i18n 键:
clients.showExpiredLeases、clients.connectionsTruncated(带{count}具名插值), zh/en 两份同时写入;「加载更多历史」按钮复用既有clients.loadMore,不新增第三个键。 - 无 DDL、无
schema_meta.version变更,因此不涉及migrations/与升级文档动作;idx_client_connection_leases_expires首次有了消费者。 - 活文档同步:
docs/user-guide/server-admin.md(Client 观测段)、docs/operations/troubleshooting.md(新增 「客户端详情里大量 WS 连接显示已过期」)、docs/operations/observability.md(新增「客户端连接租约回收」)。
安全与授权影响
- 权限判定不变且先于响应组装:仍由
clientInstanceReadable决定可读性,state=all不放宽可见范围,普通用户 看不到他人的租约历史(新增 403 断言覆盖)。 - 清扫只按
expires_at时间边界删除已过期的行,不读取也不删除任何未过期租约,也不触碰client_instance_metadata里代表真实安装身份的记录。 - 日志与指标不含 connection ID、client instance ID、Token 或会话字节;清扫是后台聚合动作,因此不写审计日志 (审计记录的是「谁做了什么」,这里没有操作者)。
limit有服务端上限 200,且判定截断靠多取一行而不是无限翻页,历史视图不能用于批量导出全部租约记录。
测试证据
红灯(本次实测输出,实现前):
$ go test ./internal/server -run TestClientAPIConnectionsDefaultToLiveLeases -count=1
--- FAIL: TestClientAPIConnectionsDefaultToLiveLeases (0.42s)
client_api_test.go:109: default connections = []string{"client_connection_1", "client_connection_2", "client_connection_3"}, want only the live lease
$ cd web && npx vitest run src/tests/clients-api.spec.ts src/tests/clients-view.spec.ts
Test Files 2 failed (2)
Tests 9 failed | 14 passed (23)
Errors 54 errors # TypeError: rows is not iterable:el-table 收到 {connections,truncated} 而不是数组
失败项包含 clients-api.spec.ts 的「asks for lease history with state and limit」与
clients-view.spec.ts 的「lists live leases by default and history only when asked」
任务 1–3 的红灯输出产生于实现它们的当段会话,未随交接带走原文,因此这里只列可复核的部分(新增测试清单与 当前绿灯):
internal/storage/client_repository_test.go:TestClientConnectionDeleteExpiredRemovesOnlyLapsedBounded(只删过期、边界取<=、limit生效、二次调用只删剩下 1 行、有效行不动)与TestClientConnectionListByInstanceLiveOnlyAndPerInstanceLimit。internal/storage/client_contract_test.go:同一夹具在 MySQL/SQLite 两种驱动上跑ListByInstance(..., true, 0)、(..., false, 2)与DeleteExpired的收敛。internal/server/client_connection_sweeper_test.go:5 项(cutoff 恰为now-24h、默认值兜底、仓库错误透出且 counter 记result=error、SetGauges(nil)不 panic、Run在首个 tick 之前先扫一轮)。internal/observability/metrics_lease_sweep_test.go:2 项(counter 名称与标签集合、经promhttp的取值)。internal/server/runtime_test.go:1619:TestNewServerRuntimeReapsExpiredClientConnectionLeases,真实 SQLite 里一条 10 天前的过期租约在 3 秒内被清扫掉。
绿灯与门禁:
go test ./... -count=1:全部包ok(internal/server67.0s、internal/storage3.6s、internal/observability0.7s)。go test -race ./internal/storage ./internal/observability -count=1:ok。go vet ./...无输出,gofmt -l internal/无输出,git diff --check干净。cd web && npm test -- --run:66 个文件 507 项通过(在 503 项基线上新增 4 项:API 2 项、视图 2 项)。npm run build通过,./scripts/verify-web-embed.sh报告web/dist与internal/server/web_dist一致。go test -race ./internal/server仍受该包既有的长测试拖住,与本次改动无关(CI 未启用该目标)。- 行为断言覆盖:默认只返回未过期行、
state=all返回三行且truncated=false、state=all&limit=1只剩最新一行 且truncated=true、state=bogus与limit=abc返回 422、非属主 403;抽屉默认一行且请求为{state:'live',limit:50},切换开关后三行含两个「已过期」标签并以{state:'all',limit:50}重新请求, 截断提示可见且「加载更多历史」把窗口翻倍为 100。
发布步骤
- 随 Server 二进制/镜像发布;无 DB 迁移、无配置项、无需重启 Agent 与 Client。
- 升级后先看指标再看表:
sum(increase(tunnelmesh_client_connection_lease_sweep_total[15m]))应有非零增量,result="error"分支为 0;SELECT COUNT(*) FROM client_connection_leases逐轮下降。 - 打开一个曾经崩溃过的客户端详情,确认默认列表只剩未过期连接;勾选「显示历史租约」确认当天历史仍可读。
- 遗留行数极多时不要干预:首轮之后每分钟收敛 1000 行,发布窗口内不安排手工
DELETE。 - 集群滚动升级期间新旧 Server 可同时访问数据库:旧节点不删除、新节点按时间边界删除,语义无冲突。
回滚步骤
- 数据是单向删除的,回滚代码不会恢复已回收的租约行;有效租约由客户端心跳在 90 秒内重建,历史行本就是过期 事实,因此影响面限于排障证据。保留窗口取 24 小时正是为此。
- 分阶段止损(每步独立,最快 5 分钟内完成):
① 摘掉
internal/server/runtime.go的 sweeper 启动块并重启——立刻停止一切删除,无需数据补偿; ② API 退回不解析state/limit(live视图等价于旧版语义,只是不再有truncated); ③ 前端退回listClientConnections(id)单参数调用。 - 完整回滚:
git revert本次提交,重新cd web && npm run build与./scripts/verify-web-embed.sh,替换web_dist并重启 Server。 - 无 Schema 变更,因此不涉及
schema_meta.version、升级 SQL 与反向 DDL。
Reviewer 关注点
DeleteExpired用「先查 id、再按主键IN (...)删除」而不是DELETE ... LIMIT:后者是 MySQL 专有语法, 前者两种驱动共用一份 SQL 且首轮可中断可续跑。代价是两次往返与一次 id 集合的物化,请确认这个取舍。- 每节点各跑一份清扫器、不引入分布式锁:与
audit_retention_sweeper.go同一论证(时间边界的幂等DELETE, 重复一轮只删 0 行)。请确认在超大表上「N 个节点各做一次索引扫描」的成本仍可接受。 Run在仓库返回错误时结束循环(照client_metadata_sweeper.go的形态,计划的 TDD 条目也这么写),因此启动 初期一次数据库抖动会让本进程的回收停到重启为止,信号只有result="error"的一次计数与Stop()里的错误。 仓库里另一个先例是audit_retention_sweeper.go的 log-and-continue。若这个取舍不可接受,应整体改成后者, 而不是在清扫器里自己加退避重试。- 保留窗口 24 小时与 TTL 90 秒是两个独立数字,回收不等于「过期即删」:排障需要当天历史,读取侧已限量。
- 列表页
ListByInstances的limit是每实例上限,在 Go 侧用计数器实现(无可移植的 group-limit SQL); API 层对列表页传0(不限量),因为那里只要 live 行。 truncated由「多取一行」判定而不是COUNT(*):它表示「还有更多行」而非精确总数,请确认这一近似足够。state非法走 422 +allowed、limit越界走静默钳制:两类参数各自沿用本仓库既有先例,刻意没有统一。- 前端
showDetail的失败语义变化(连接读取失败不再折叠整个抽屉)是顺带修复,不在计划正文里,请确认。 - 清扫不写审计日志:如果将来要求「删除记录可追溯到操作者」,需要先定义系统 Actor 的语义,而不是给它加标签。
集成状态
- 实施计划已获用户确认后执行,6 个任务全部完成,勾选与偏差记录已补记在计划末尾。
go test ./... -count=1、go vet ./...、gofmt -l internal/、git diff --check、npm test -- --run、npm run build、./scripts/verify-web-embed.sh全部通过。- 文档索引已由
python3 scripts/gen_doc_index.py重新生成。 - 尚未 commit、push 或创建远端 PR,等待用户授权。