客户端观测状态模型修复实施计划
客户端观测状态模型修复实施计划
状态:已确认并完成实施,实现记录见 docs/pull-requests/2026-09-24-client-status-metadata-state.md。第 7、8 节的接口命名已按最终实现同步。
日期:2026-09-24
1. 目标
让「客户端运行观测」列表的状态与服务端真实事实一致:在线客户端不再被标成「metadata 未上报」或「metadata 已过期」,断开的客户端不再留下永久「已过期」幽灵行,表格上方的统计量与表格同源。
2. 非目标
- 不新增协议 frame,不修改 metadata TTL(5 分钟)、心跳间隔(30 秒)或窗口/流控参数。
- 不做 Schema 变更:本次不新增列、不提升
SchemaVersion、不写增量脚本。 - 不改动 Agent 侧观测逻辑(
agent_instance_metadata已在 #21 修复)。 - 不改动授权、Token scope、路由与代理数据面。
3. 根因与线上证据
证据来源:生产库 tunnelmesh(client_instance_metadata 27 行)与 GET /api/v1/clients 实际响应。
3.1 在线客户端被判为「metadata 未上报」
internal/client/metadata.go的ClientMetadataOptions没有任何Capabilities生产者,Client 也从不发送capabilities,因此protocol.ClientMetadataPayload.Capabilities恒为 nil。persistMetadata(internal/server/client_observability.go:181起)执行json.Marshal(payload.Capabilities),nil slice 序列化成 4 字节字符串"null"。clientInstanceRepo.Upsert只对""归一化为"[]"(internal/storage/client_repository.go:35),漏掉"null",于是库里存的是字符串"null"。newClientView(internal/server/client_api.go:272)用decodeStrings(instance.Capabilities) == nil表示「未上报 metadata」:json.Unmarshal("null", &v)成功且v为 nil,命中该分支。- 结果:5 个带完整 hostname/version/listeners 的在线客户端全部显示「metadata 未上报」。线上采样:
client-1b0dee98…(devbook-pro,v1.2.6-4-g6241fe3,4 连接 4 流)capabilities字段为"null",API 返回"capabilities":null、"status":"metadata_unavailable";而 legacy 行(capabilities="[]"→[]string{}非 nil)反而返回online。
3.2 永久「metadata 已过期」幽灵行
RegisterLegacy(internal/server/client_observability.go:105)用"legacy-" + principal.ConnectionID作为 instance_id,即每条物理 WebSocket 一行。- Client 连接池/重连每次产生新行;旧行的
expires_at在最后一次心跳 5 分钟后被MarkExpired(internal/storage/client_repository.go:152)置stale=1。 ClientInstanceRepository没有任何删除方法(internal/storage/repository.go:230),ClientMetadataSweeper只标记不回收,因此幽灵行永不消失。- 线上现状:27 行中 21 行
stale=1,且 21 行全部instance_id LIKE 'legacy-%',最早一条last_seen_at=2026-09-23T10:28:42Z(约 23 小时前)。同一个物理客户端因此表现为「1 条在线 + N 条已过期」。
3.3 状态枚举把「presence」和「metadata 新鲜度」混在一起
newClientView优先级是stale>metadata_unavailable>online,offline只是兜底:一条连接已断开的行会显示「metadata 已过期」,而不是「离线」;语义上「已过期」描述的是 metadata 时效,不是节点存在性。- 同一缺陷使统计量互斥失真:当前 6 个在线客户端里有 5 个同时被计入「metadata 未上报」。
3.4 统计量与表格不同源
web/src/views/Clients.vue:203的 5 张卡片全部基于items.value(当前游标页、当前筛选),不是全量聚合;分页或筛选后与表格、与实际总数不一致。这正是上一轮反馈的「表格上方这些量也需要同步」。
4. 架构决策
- D1 拆分正交事实(单一职责):
ClientView.status只表达 presence——有未过期 lease 即online,否则offline。metadata 时效独立为ClientView.metadataState,取值reported/unavailable/expired。二者各自只有一个权威来源:presence 来自client_connection_leases.expires_at,时效来自client_instance_metadata.{metadata,stale}。 - D2 兼容性:
status与metadataState都是新增语义,不删字段。GET /api/v1/clients?status=stale|metadata_unavailable保留为metadataState=expired|unavailable的查询别名,OpenAPI 与代码注释标记 Deprecated,至少保留一个 MINOR 版本后再删。 - D3 统计量服务端聚合:新增
ClientInstanceRepository.Summarize,在同一 filter 下用一条 SQL 返回total/online/activeConnections/activeStreams/metadataUnavailable/metadataStale。前端只渲染服务端数字,不再items.filter(),保持单一数据源。 - D4 判别「是否上报过 metadata」用持久事实:
persistMetadata写入的 payload JSON 必含非空instance_id(ClientMetadataPayload.InstanceID无omitempty,且validateClientMetadataPayload强制非空),而RegisterLegacy写入字面量{}。因此以metadata='{}'判定「未上报」,客户端无法伪造,也不需要新增列。Go 侧对应payload.InstanceID == ""。 - D5 legacy 行随连接生命周期回收:
Release调用DeleteUnreported删除该连接持有的行;ClientMetadataSweeper每轮在MarkExpired之后调用PurgeUnreported,清理崩溃残留与现存的 21 条幽灵。两者的删除条件都是metadata='{}',不依赖instance_id LIKE:LIKE 的_是通配符,语义不稳定,而{}只有RegisterLegacy能写出。 - D7 判定条件单点化:Go 侧
clientMetadataReported与 SQL 侧metadata='{}'必须表达同一事实,否则行标签与统计卡片会互相矛盾。 - D6 在存储边界归一化 capabilities:
Upsert把""与"null"一并归一化为"[]"。放在 Repository 而非 Handler,使所有写入方(含未来的 capabilities 生产者)都不会再泄漏 JSONnull到 API。
5. 技术栈与规格引用
- Go 1.26 标准库
database/sql+github.com/go-sql-driver/mysql+modernc.org/sqlite;聚合 SQL 必须同时通过两种驱动(migrations/ddl.sql:288起,时间列是 TEXT 存 RFC3339Nano,比较为字典序,因此只使用CASE WHEN EXISTS与既有时间串比较,不引入数据库端时间函数)。 - MySQL 下限是 5.6(
docs/operations/connection-pool.md与docs/operations/schema-upgrades.md已按 5.6 的 767-byte 前缀限制设计):不使用 CTE、窗口函数、JSON 函数与任何 8.0 专属语法;生产实例VERSION()=5.6.51-91.0-log,聚合 SELECT 与两条 DELETE 的兼容性按计划第 11 节只读核验。 - Vue 3 + TypeScript + Element Plus;
web/src/api/client.ts、web/src/views/Clients.vue。 - 规范依据:
AGENTS.md「分层」「API:统一响应/cursor 分页」「一致性」「TDD」「协议演进约束」;docs/protocol/proxy-modules.md不受影响;文档索引由scripts/gen_doc_index.py生成。
6. 全局约束
- Handler 只做协议与授权,presence/时效判定下沉到
newClientView与 Repository;禁止在 Handler 直接写 SQL。 - 普通用户仅能看到自己的客户端;
Summarize必须复用与List完全相同的clientInstanceConditions,避免统计越权。 - 日志与响应不得包含 secret、Token 明文、完整 Authorization。
- 不修改
migrations/ddl.sql、不提升SchemaVersion。
7. 文件清单
服务端:
internal/storage/repository.go—ClientInstanceRepository增加DeleteUnreported、PurgeUnreported、Summarize;新增ClientInstanceSummary结构。internal/storage/client_repository.go—Upsert归一化null/""capabilities;实现三个新方法;clientInstanceConditions的Status收窄为 presence,新增MetadataState条件。internal/server/client_api.go—ClientView增加MetadataState;newClientView按 D1/D4 重写状态推导;listClients输出summary并解析metadataState过滤与 Deprecated 别名。internal/server/client_observability.go—Release删除本次连接持有的 legacy 行;暴露reported(instance)判定所需的最小 helper。internal/server/client_metadata_sweeper.go— 每轮在MarkExpired后调用PurgeUnreported。internal/server/api.go—decodeStrings保持不变;newClientView局部把 nil capabilities 归一成[],避免影响其它列的既有解码语义。
前端:
web/src/api/client.ts—ClientStatus = 'online' | 'offline'、新增ClientMetadataState、ClientListParams.metadataState、ClientListSummary与ClientListPage.summary。web/src/views/Clients.vue— 状态列拆成 presence 标签 + metadata 标签;筛选器增加 metadata 维度;5 张卡片改读summary。web/src/i18n/messages/zh-CN.ts、web/src/i18n/messages/en-US.ts— 新增clients.metadataStateLabel.*与筛选文案,statusLabel.offline保持「离线」。
文档:
docs/api/openapi.yaml—/api/v1/clients增加metadataState查询参数与summary响应;status的stale|metadata_unavailable标注 deprecated。docs/README.md— 索引再生成(scripts/gen_doc_index.py)。docs/operations/upgrade-and-rollback.md(若存在同名文件则追加,否则在docs/operations/现有升级文档中补一节)— 说明本次无 Schema 变更、可直接回滚应用。docs/help/(客户端观测用户帮助)— 更新状态含义:在线/离线与 metadata 三种时效各自独立。
测试:
internal/storage/client_repository_test.gointernal/server/client_api_test.gointernal/server/client_observability_test.gointernal/server/client_metadata_sweeper_test.goweb/src/tests/clients-api.spec.tsweb/src/tests/views.spec.ts
PR 记录:docs/pull-requests/2026-09-24-client-status-metadata-state.md
8. 任务与接口
任务顺序执行,T1 是后续所有断言的基础。
T1 Repository:capabilities 归一化 + presence/时效聚合
接口:
type ClientInstanceSummary struct {
Total int64
Online int64
ActiveConnections int64
ActiveStreams int64
MetadataUnavailable int64
MetadataStale int64
}
func (ClientInstanceSummary) IsZero() bool
type ClientInstanceRepository interface {
// ...
DeleteUnreported(context.Context, string) error
PurgeUnreported(context.Context, time.Time) (int64, error)
Summarize(context.Context, ClientInstanceFilter, time.Time) (ClientInstanceSummary, error)
}
ClientInstanceFilter 增加 MetadataState string(reported|unavailable|expired)。Summarize 与 List 共用 clientInstanceConditions。
TDD:
- 红:
TestClientInstanceUpsertNormalizesNullCapabilities断言写入capabilities:"null"后读回[]";当前实现返回"null"而失败。 - 红:
TestClientInstanceSummaryMatchesFilter(MySQL 与 SQLite 两个子测试)造 3 行:reported+live lease+2 流、legacy 活、legacy 死(stale=1),断言Total=3,Online=3,ActiveConnections=…,ActiveStreams=…,MetadataUnavailable=2,MetadataStale=…;metadataState=reportedfilter 下Total=1。 - 红:
TestClientInstancePurgeUnreportedOrphans与TestClientInstanceDeleteUnreportedKeepsReportedRows断言只删无未过期 lease 且已过 TTL 的metadata='{}'行。 - 最小实现:
Upsert归一化条件改为capabilities == "" || capabilities == "null";PurgeUnreported用DELETE ... WHERE metadata='{}' AND (expires_at IS NULL OR expires_at<=?) AND NOT EXISTS(SELECT 1 FROM client_connection_leases ... expires_at>?);Summarize用一条实例级SELECT COUNT(*), SUM(CASE WHEN EXISTS(...))加一条同 WHERE 的租约级聚合,避免重复拼接三份 filter 参数。 - 绿:两条命令见第 11 节。
T2 Server:presence/时效状态模型
接口:ClientView.MetadataState string \json:”metadataState”`,取值 fresh|expired|unavailable;status 语义收窄为 online|offline。filter 侧 reported 是 fresh|expired` 的别名。
TDD:
- 红:
TestNewClientViewSplitsPresenceFromMetadataState表驱动 5 例——(a) live lease + 已上报 +stale=0→online/fresh;(b) live lease + 已上报 +stale=1→online/expired;(c) live lease +metadata='{}'→online/unavailable;(d) 无 lease +stale=1+metadata='{}'→offline/unavailable(原实现返回stale,即用户看到的「已过期」);(e) 过期 lease + 已上报 →offline/fresh。原实现在 (a) 即失败。 - 红:
TestListClientsReturnsSummaryAndMetadataStateFilter断言响应data.summary.online == 全量在线数且?metadataState=expired生效;?status=stale仍可用(别名)。 - 最小实现:按 D1/D4 重写
newClientView推导;listClients解析metadataState、Deprecated 别名映射到MetadataState、返回summary。 - 绿。
T3 Server:legacy 行回收
TDD:
- 红:
TestClientObservabilityReleaseDropsUnreportedRow断言 legacy 连接Release调用DeleteUnreported;TestClientObservabilityKeepsReportedRowOnRelease断言仓库返回sql.ErrNoRows(行已上报)时Release仍然成功。 - 红:
TestClientMetadataSweeperPurgesUnreportedOrphans断言每轮Sweep在MarkExpired之后调用PurgeUnreported。回收量已由PurgeUnreported的返回值可观测,本次不新增指标名,避免与既有client_instance_metadata陈旧度指标重复。 - 最小实现:
Release无条件尝试DeleteUnreported(record.ClientInstanceID),把「是否可删」的判断留在仓库的metadata='{}'条件里,避免内存态与真实行不一致;sweeper 在MarkExpired之后调用PurgeUnreported并返回其影响行数。 - 绿。
T4 Web:状态列与同源统计
TDD:
- 红:
web/src/tests/clients-api.spec.ts断言listClients透传metadataState并解析summary。 - 红:
web/src/tests/views.spec.ts对 Clients 视图断言渲染「在线」+「metadata 未上报」两个标签,且卡片数字取自summary而非当页 items。 - 最小实现:
statusKind只映射 presence;新增metadataStateKind/Label;卡片value读summary.value?.[...] ?? 0。 - 绿:
npm test -- --run、npm run build,随后重新生成 embed 产物并跑scripts/verify-web-embed.sh。
T5 文档与 OpenAPI
按第 7 节清单更新,执行 python3 scripts/gen_doc_index.py,不得手工编辑索引。
9. 预期结果
线上应看到:21 条 legacy-% 幽灵行被回收;6 个在线客户端的 status 全为 online;其中 5 个 metadataState=reported、1 个为 unavailable;metadataState=expired 计数为 0;卡片数字与全量一致。
10. 回滚注意事项
- 无 Schema 变更:回滚 = 回退应用版本并重启
tunnelmesh-server,5 分钟内可完成,不需要反向 SQL。 - 旧版 UI 读新版 API:
status值域收窄为online|offline,旧前端会少显示两种状态但不报错;新版 UI 读旧版 API 时summary/metadataState缺失,前端以?? 0/'reported'兜底,因此可单侧先回滚 Server 或 Web。 - 已删除的 legacy 行是纯观测数据,无业务事实,删除不可逆但不影响功能;如需保留,回滚前
CREATE TABLE client_instance_metadata_backup_20260924 AS SELECT * FROM client_instance_metadata。 - 灰度:多 Server 节点必须整批升级到同一版本,避免新旧节点对同一行写入不同
metadataState语义。
11. 验证命令
go test ./internal/storage -run 'ClientInstance|Purge|Capabilities' -count=1
go test ./internal/server -run 'ClientView|ClientMetadataSweeper|Release|ListClients' -count=1
go test ./... -count=1
go test -race ./...
go vet ./...
cd web && npm test -- --run && npm run build
./scripts/verify-web-embed.sh
git diff --check
MySQL 5.6 只读核验(在目标实例上执行,聚合 SELECT 可执行,DELETE 只允许 EXPLAIN/PREPARE,禁止 EXECUTE):
SELECT VERSION(); -- 期望 >= 5.6
SELECT COUNT(*),
COALESCE(SUM(CASE WHEN EXISTS (SELECT 1 FROM client_connection_leases c
WHERE c.client_instance_id=client_instance_metadata.id AND c.expires_at>?) THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN metadata='{}' THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN stale=1 AND metadata<>'{}' THEN 1 ELSE 0 END),0)
FROM client_instance_metadata;
EXPLAIN DELETE FROM client_instance_metadata
WHERE metadata='{}' AND (expires_at IS NULL OR expires_at<=?)
AND NOT EXISTS (SELECT 1 FROM client_connection_leases c
WHERE c.client_instance_id=client_instance_metadata.id AND c.expires_at>?);
EXPLAIN 必须产出计划且不得返回 1093(Can't specify target table ... for update in FROM clause)。
部署后手工核验:
curl -sS 'https://tunnelmesh-admin.example.com/api/v1/clients?limit=50' -H 'accept: application/json' -H "authorization: Bearer $TM_TOKEN" | jq '.data.summary, [.data.items[] | {status, metadataState, activeConnections}]'