Client 观测状态模型修复
Client 观测状态模型修复
Title
fix(server): split client presence from metadata state
Target branch
main
Summary
Clients 列表把“是否存在”和“metadata 是否新鲜”拆成两个字段:status 只由未过期连接租约决定,metadataState 由 CLIENT_HELLO 上报事实与 TTL 决定。同时修复了在线客户端被判成 metadata_unavailable 的推导缺陷、未上报实例记录永久残留成“已过期”幽灵行的问题,并把统计卡片改为服务端按筛选条件聚合的全量数字。
实施计划见 docs/superpowers/plans/2026-09-24-client-status-metadata-state.md。
问题与根因
线上 tunnelmesh 库 27 行 client_instance_metadata 与 GET /api/v1/clients 实测:
persistMetadata对payload.Capabilities执行json.Marshal。Client 协议字段是capabilities,omitempty且 Client 侧没有该字段的生产者,nil slice 序列化成字符串"null";clientInstanceRepo.Upsert只把""归一化成"[]",于是"null"入库。decodeStrings("null")返回 nil,newClientView把 nil 当作“未上报 metadata”,因此 5 个带完整 hostname/version/listeners 的在线客户端全部显示metadata_unavailable;反倒是metadata='{}'、capabilities="[]"的 legacy 行显示online。RegisterLegacy使用"legacy-" + connectionID作为 instance_id,即每条物理连接一行。Client 连接池与重连每次产生新行,旧行 5 分钟后被MarkExpired置stale=1,而ClientInstanceRepository没有任何删除能力,ClientMetadataSweeper只标记不回收。21 行stale=1记录全部是legacy-%,最早一条last_seen_at=2026-09-23T10:28:42Z。同一台机器表现为“1 条在线 + N 条 metadata 已过期”。newClientView的优先级是stale>metadata_unavailable>online,offline只是兜底,导致已断开的记录显示“metadata 已过期”而不是“离线”,也让“在线”与“未上报”两个统计互相矛盾。web/src/views/Clients.vue的 5 张卡片由items.value.filter/reduce计算,即当前游标页,而不是筛选后的全量,因此与表格和真实总数不一致。
User impact
- 在线客户端不再显示
metadata 未上报;presence 与 metadata 时效分列展示,GET /api/v1/clients每个 item 新增metadataState。 - 断开的历史行显示
offline,并在下次清扫时被回收,列表不再堆积“metadata 已过期”幽灵。 - 统计卡片与服务端聚合一致,翻页与筛选后仍然正确。
API、Schema 与配置影响
GET /api/v1/clients响应新增summary对象与 item 字段metadataState;status值域收窄为online|offline。- 查询参数新增
metadataState(fresh|expired|unavailable|reported)。status=stale|metadata_unavailable保留为metadataState=expired|unavailable的 Deprecated 别名,一个 MINOR 版本后再删除。openapi.yaml已标注deprecated: true。 - 无数据库 Schema 变更:不新增列、不修改
migrations/ddl.sql、不提升SchemaVersion,因此docs/operations/schema-upgrades.md无需版本章节。 - 无配置项、无协议 frame 变更。
安全与授权影响
Summarize复用clientInstanceConditions,与List同一 WHERE,统计不会越权暴露他人 Client。metadataState判定依赖服务端持久事实:persistMetadata写入的 payload 必含非空instance_id,{}只可能由RegisterLegacy写出,Client 无法伪造“已上报”。DeleteUnreported/PurgeUnreported只匹配metadata='{}',且 purge 额外要求无未过期租约并已过 TTL,因此已上报实例与握手过程中的记录不会被删。- 未新增日志字段,不输出 metadata 值、Token 或凭据。
测试证据
- 红灯:
TestClientInstanceUpsertNormalizesNullCapabilities修复前读回"null";TestClientInstanceSummaryMatchesFilter、TestClientInstancePurgeUnreportedOrphans、TestClientInstanceStatusFilterIsPresenceOnly、TestClientInstanceDeleteUnreportedKeepsReportedRows因Summarize/PurgeUnreported/DeleteUnreported/MetadataState不存在而编译失败。 - 红灯:
TestNewClientViewSplitsPresenceFromMetadataState修复前connected and fresh用例返回metadata_unavailable;TestClientAPIListReportsWholePopulationSummary、TestClientAPIFiltersMetadataStateAndAcceptsDeprecatedStatus因metadataState/summary缺失失败;TestClientObservabilityReleaseDropsUnreportedRow、TestClientMetadataSweeperPurgesUnreportedOrphans因未回收而失败。 - 绿灯:
go test ./internal/storage -run 'ClientInstance|Purge|Capabilities' -count=1、go test ./internal/server -run 'ClientView|ClientAPI|ClientObservability|ClientMetadataSweeper|PersistedClientHello' -count=1通过。 - 前端红灯:
clients-api.spec.ts三条新用例与clients-view.spec.ts汇总用例先失败;web/src/tests/clients-view.spec.ts重写为断言卡片来自服务端summary,并新增“在线 + metadata 已过期”双标签渲染用例。 - 前端绿灯:
npm test -- --run35 文件 302 用例通过;npm run build成功且scripts/verify-web-embed.sh报告web/dist and internal/server/web_dist match。 go test ./... -count=1通过。go vet ./...通过。git diff --check通过。
发布步骤
- 多节点集群需整批部署同一版本的新版
tunnelmesh-server后滚动重启;无需 DB 变更前置步骤。 tunnelmesh-agent与tunnelmesh-client不需要升级:本次未改动协议、Agent 与 Client 代码路径。- 浏览器需强制刷新以获取新的管理后台产物(嵌入在 Server 二进制内)。
- 若希望在升级前留档,可执行
CREATE TABLE client_instance_metadata_backup_20260924 AS SELECT * FROM client_instance_metadata;。 - 上线后核验:
GET /api/v1/clients?limit=100的summary.total与items全量一致,且在线客户端status=online。
回滚步骤
- 回退
tunnelmesh-server到上一版本并重启即可,Schema 未变更,不需要反向 SQL。 - 被回收的
legacy-%行是纯观测数据,不承载业务事实,删除不可逆但不影响功能;如需精确还原,使用发布步骤第 4 条的备份表。 - 旧版 Server 读取新版数据无兼容问题(未使用
metadataState列,判定字段stale/metadata语义未变)。
Reviewer 关注点
internal/server/client_api.go的clientMetadataReported与internal/storage/client_repository.go的metadata='{}'条件必须是同一判定,二者不一致会让卡片与行标签矛盾。Summarize的时间比较沿用仓库既有的 RFC3339 文本字典序约定,未引入数据库端时间函数;请确认对VARCHAR(32)/TEXT时间列的依赖符合预期。PurgeUnreported每 30 秒扫描一次client_instance_metadata,谓词metadata='{}'与expires_at都无索引,故为全表扫描。该表规模由 Client 安装数决定,且回收本身会让它收敛,因此未把stale=1加进谓词去蹭idx_client_instance_metadata_stale:那会让删除正确性依赖“MarkExpired必须先跑”的调用顺序。- MySQL 语法兼容性:CI 未启用
TUNNELMESH_TEST_MYSQL_DSN,因此按项目下限 MySQL 5.6 在生产库(VERSION()=5.6.51-91.0-log,sql_mode=NO_ENGINE_SUBSTITUTION)做了只读核验。Summarize的两条聚合 SELECT 实际执行,返回total=27, online=6, metadataUnavailable=22, metadataStale=0, activeConnections=16, activeStreams=96,与逐项解算一致;即SUM(CASE WHEN EXISTS (相关子查询) ...)与IN (SELECT ...)在 5.6 上可用。- 两条 DELETE 用
EXPLAIN生成执行计划(不执行):PurgeUnreported计划为PRIMARY client_instance_metadata+DEPENDENT SUBQUERY c(命中idx_client_connection_leases_instance),未触发 1093“target table”限制;DeleteUnreported走PRIMARYrange。 - 对照组:
PREPARE一条引用不存在列的语句确实返回Error 1054,证明该语法检查有效,而不是恒真。 - 未使用 CTE、窗口函数、JSON 函数、
ALGORITHM=INSTANT、8.0 专属语法,也不依赖utf8mb4索引前缀超过 767 byte。
- 因此合并后首次清扫预计把
metadataUnavailable从 22 降到 1(仅剩当前仍在线、且未协商 metadata 子协议的旧客户端),metadataStale保持 0。
集成状态
实施计划:docs/superpowers/plans/2026-09-24-client-status-metadata-state.md(已确认)。