客户端运行观测状态不同步修复实施计划
客户端运行观测状态不同步修复实施计划
状态:已确认(用户指令“所有问题处理完,创建 mr 合并 main”)
1. 现象
客户端运行观测页(/clients)出现三处互相矛盾的数据:
- 列表行的「活跃 WS 连接」「活跃流」恒为
0,「Server 节点」为—。 - 列表行的状态显示「metadata 已过期」(
stale),但客户端进程确实在线且持续收发。 - 打开详情抽屉,「WS 连接」表格却列出 4 条连接,且「连接 Epoch」全部是
2147483647。 - 表格上方的汇总卡片「在线客户端 / 活跃 WS 连接 / 活跃流 / metadata 未上报」全为
0,与详情抽屉不一致。
2. 根因
根因 A:MySQL 把 64 位连接 Epoch 截断成 32 位
internal/server/ws_client.go:553的newClientConnectionEpoch()用 8 字节随机数生成int64epoch,作为 Client 物理连接的 fencing token。migrations/ddl.sql:310与migrations/incremental/v0010_to_v0011/mysql.sql:23把client_connection_leases.connection_epoch声明为INTEGER。MySQL 的INTEGER是有符号 32 位,最大2147483647。- 非严格模式下 MySQL 把超范围值钳制为
2147483647写入,这正是详情抽屉里 4 条连接 Epoch 全部相同的原因。 clientConnectionRepo.Renew(internal/storage/client_repository.go:226)与UpdateStats、Release都以WHERE connection_id=? AND connection_epoch=?作为条件,传入的是内存中的真实 int64 epoch,与库里的2147483647永远不相等 →RowsAffected()=0→checkAffected返回sql.ErrNoRows。- 对照组:
agent_connection_leases.connection_epoch在migrations/incremental/v0006_to_v0007/mysql.sql:8是BIGINT,且 agent 的 epoch 是从小整数自增(internal/agent/websocket.go:132),所以 Agent 侧没有触发该缺陷。
根因 B:租约续期失败会连带吞掉 metadata 续期
ClientObservabilityService.Heartbeat(internal/server/client_observability.go:134):
if err := s.leases.Heartbeat(ctx, record); err != nil {
return err // ← 提前返回
}
return s.instances.TouchInstance(ctx, ...)
根因 A 让 s.leases.Heartbeat 每次都返回 sql.ErrNoRows,于是 TouchInstance 永远不会执行:
client_instance_metadata.expires_at停留在上一次 metadata 上报时刻,超过DefaultClientMetadataTTL = 5 * time.Minute后被ClientMetadataSweeper标记stale=1(internal/server/client_metadata_sweeper.go:43)→newClientView返回status="stale"(internal/server/client_api.go:270)→ 前端显示「metadata 已过期」。client_connection_leases.expires_at同样不再续期,超过DefaultClientConnectionLeaseTTL = 90 * time.Second后,newClientView的connection.ExpiresAt.After(now)判定为假 →activeConnections、activeStreams、serverNodeIDs全部为空。- 汇总卡片(
web/src/views/Clients.vue:196)完全由列表行派生,因此一起归零。
一个 32 位列宽问题,经由「提前返回」放大成了整页观测数据失效。
根因 C:详情抽屉把已过期租约渲染成活跃连接
GET /api/v1/clients/{id}/connections 返回 ListByInstance 的全部租约行,不区分是否过期;ClientConnectionView 已经带有 ExpiresAt(internal/server/client_api.go:57),但前端没有据此标注状态,也仍然允许对已过期租约点「关闭连接」。这就是「列表 0 条活跃、详情 4 条连接」这一自相矛盾的观感来源。
根因 D:全量 DDL 与增量脚本漂移
migrations/ddl.sql:261 把 agent_connection_leases.connection_epoch 写成 INTEGER,而权威增量 v0006_to_v0007/mysql.sql:8 是 BIGINT。空库 auto-init 走 migrations.DDL(internal/storage/db.go:325),因此全新 MySQL 部署拿到的是窄类型,升级上来的库反而是宽类型,违反「migrations/ddl.sql 是当前 Schema 状态的唯一权威全量脚本」。该漂移没有被发现,是因为 MySQL 相关测试由 TUNNELMESH_TEST_MYSQL_DSN 网关控制,CI 中不执行,而 SQLite 的 INTEGER 本身就是 64 位。
3. 目标
- MySQL 下
connection_epoch能无损保存 int64 fencing token,租约续期、统计更新与释放恢复生效。 - 已经连接中的 Client 在 Server 升级后无需重连即可自愈。
- metadata 存活判定不再被租约写入失败连带拖垮:物理 WebSocket 在线本身就是实例存活的证据。
- 详情抽屉如实区分「活跃租约」与「已过期租约」,过期租约不再提供关闭入口。
- 汇总卡片覆盖表格中实际出现的
stale状态,卡片与行不再各说各话。 - 消除全量 DDL 与增量脚本在 epoch 列宽上的漂移,并补上 SQLite 侧就能执行的防漂移测试。
4. 非目标
- 不缩小 epoch 取值范围去迁就 32 位列。epoch 是 fencing token,削弱它等于削弱 stale generation 防护。
- 不在迁移脚本里回写已被钳制的历史行。那些租约早已过期,活跃连接由心跳自愈负责修正。
- 不改 Agent 侧的 epoch 生成与租约机制(仅同步列宽,保持 DDL 与增量一致)。
- 不引入新配置项,不改心跳间隔与 TTL 默认值。
- 不新增 API,
ClientConnectionView字段保持不变。
5. 设计决策
决策 1:Schema 变更走 v14 → v15,MySQL 加宽、SQLite 空操作
新增 migrations/incremental/v0014_to_v0015/:
mysql.sql:ALTER TABLE client_connection_leases MODIFY connection_epoch BIGINT NOT NULL;与ALTER TABLE agent_connection_leases MODIFY connection_epoch BIGINT NOT NULL;。MODIFY只放宽类型,不重写数据语义,MySQL 5.6+ 均支持;对已经是BIGINT的库重复执行是幂等的(类型相同,不报错),满足「增量迁移必须可安全重试」。sqlite.sql:SQLite 的INTEGER已是 64 位,且不支持ALTER COLUMN,因此结构上无需变更。脚本执行一条幂等空操作UPDATE schema_meta SET version=version WHERE id=1;并在注释中说明原因,避免applySchemaStatements收到纯注释脚本。
同步更新 migrations/ddl.sql 两处 connection_epoch 为 BIGINT(:261 agent、:310 client),并把 internal/storage/db.go:23 的 SchemaVersion 从 14 提升到 15,在 initializeSchema 的 switch 中补 case 14,在 migrations/migrations.go 补 V14ToV15MySQL / V14ToV15SQLite 的 go:embed。
版本语义:这是向后兼容的列宽扩展(旧二进制读 BIGINT 列仍得到 int64),按 AGENTS.md 属于 MINOR 级别 Schema 变更,允许滚动升级期间新旧 Server 同时访问数据库。
决策 2:心跳自愈——续期匹配不到行就用权威 epoch 重新登记
ClientConnectionLeaseController.Heartbeat(internal/server/client_connection_lease.go:59)在 Renew 返回 sql.ErrNoRows 时,回退调用 Register 用内存中的真实 epoch 覆盖该行:
- 内存
ClientSessionManager是活跃连接计数的权威来源,租约只是「有界持久快照」,用权威值修正快照符合既有的单一数据源约束。 Register已有connection_id归属校验与connection_epoch >= existingEpoch的单调性保护,重登记不会破坏 fencing,也不会跨实例串号。- 这让生产环境在执行迁移之前就已连接中的 Client 也能在下一次心跳(≤30s)恢复,无需等待客户端重连。
- 自愈还要求
record.ClientInstanceID非空。CLIENT_HELLO(或 legacy 降级)之前租约行本就还不存在,此时建行会留下一条无法被任何按实例查询归因的孤儿记录,破坏「每条租约都指向一个已知客户端实例」的既有不变量。 - 只有当
Renew返回的是其他错误(连接中断、SQL 失败)时才向上抛出,避免把真实故障伪装成自愈。
决策 3:metadata 续期与租约续期解耦
ClientObservabilityService.Heartbeat 改为两步都执行、用 errors.Join 汇总错误:
- 租约写入失败不再阻止
TouchInstance。物理 WebSocket 仍在收帧,实例存活是既成事实,stale标记应只反映「客户端真的不再上报」,而不是「某张表的写入路径出过问题」。 - 仍然返回错误,让调用方与日志能观察到租约侧异常,不做静默降级。
TouchInstance自身失败(例如实例行尚未由 metadata hello 建立)也照常上报,保持与现有语义一致。
决策 4:详情抽屉按 expiresAt 标注租约状态
web/src/views/Clients.vue的「WS 连接」表格新增一列,用StatusTag渲染live/expired(复用现有success/info语义),判定依据是expiresAt > now,与后端newClientView的activeConnections口径完全一致。- 已过期租约不再渲染「关闭连接」按钮:关闭一条不存在的连接只会得到
stale_epoch或remote_node_unavailable,是无效操作。 - 判定使用一个随详情加载刷新的
now基准,避免抽屉长时间停留时状态漂移。
决策 5:汇总卡片覆盖 stale
summaryCards 增加第 5 张「metadata 已过期」卡片,统计 status === 'stale' 的行数。CSS 网格从 repeat(4, ...) 调整为 repeat(5, ...),760px 以下仍回落为两列。这样卡片集合与表格状态列的取值域一一对应,用户看到的每个状态都能在汇总里找到对应量。
决策 6:补 SQLite 侧可执行的防漂移测试
现有 TestSQLiteFreshSchemaMatchesIncrementalIdentityChain 只比对 identity 相关列名,MySQL 类型断言又全部被 TUNNELMESH_TEST_MYSQL_DSN 网关挡住,这正是漂移能长期存活的原因。新增纯文本断言测试:
migrations.DDL中agent_connection_leases与client_connection_leases的connection_epoch必须是BIGINT。migrations.V14ToV15MySQL必须同时加宽两张表,且不得使用 MySQL 5.6 不兼容的片段。SchemaVersion与增量目录数量、initializeSchema的case分支保持连续。
6. 精确文件清单
新增
migrations/incremental/v0014_to_v0015/mysql.sqlmigrations/incremental/v0014_to_v0015/sqlite.sqldocs/superpowers/plans/2026-09-23-client-lease-epoch-observability-sync.md(本文件)docs/pull-requests/2026-09-23-client-lease-epoch-observability-sync.mdweb/src/tests/clients-view.spec.ts
修改
migrations/ddl.sql:两处connection_epoch INTEGER→BIGINTmigrations/migrations.go:新增V14ToV15MySQL/V14ToV15SQLiteembedinternal/storage/db.go:SchemaVersion15;initializeSchema补case 14internal/storage/client_repository_test.go:TestSchemaVersionIs14→TestSchemaVersionIs15;新增 int64 epoch 往返测试与 DDL 漂移断言internal/storage/sqlite_test.go:新增 v14 → v15 升级链测试internal/storage/mysql_test.go:Schema 版本断言 14 → 15internal/server/client_connection_lease.go:Heartbeat自愈回退internal/server/client_connection_lease_test.go:自愈测试internal/server/client_observability.go:Heartbeat解耦 +errors.Joininternal/server/client_observability_test.go:租约失败仍续期 metadata 的测试web/src/views/Clients.vue:租约状态列、过期禁止关闭、第 5 张卡片、网格列数web/src/i18n/messages/zh-CN.ts/en-US.ts:clients.leaseState、clients.leaseStateLabel.{live,expired}、clients.metadataStaleweb/src/i18n/schema.ts:同步消息 schema(若该文件枚举键)docs/operations/schema-upgrades.md:新增 v14 → v15 章节docs/operations/troubleshooting.md:新增排查条目docs/superpowers/plans/README.md、docs/pull-requests/README.md、docs/README.md:由scripts/gen_doc_index.py重新生成
7. TDD 步骤
步骤 1:Schema 与漂移断言(先红)
在 internal/storage/client_repository_test.go 新增:
TestSchemaVersionIs15(替换TestSchemaVersionIs14)。TestClientConnectionLeaseStoresFullInt64Epoch:用math.MaxInt32+12345这类超出 32 位的 epoch 执行Register→Renew→UpdateStats→Release,断言每一步都不返回sql.ErrNoRows,且Get读回的 epoch 与写入值完全相等。TestMigrationDDLSetsConnectionEpochBigInt:文本断言migrations.DDL两张租约表的connection_epoch为BIGINT。TestV14ToV15MySQLWidensBothLeaseTables:断言脚本包含两条MODIFY connection_epoch BIGINT NOT NULL,且不含 MySQL 5.6 不兼容片段。
预期失败:SchemaVersion = 14, want 15;DDL 断言报 INTEGER;V14ToV15MySQL 未定义导致编译失败。
步骤 2:最小实现 Schema 变更(转绿)
按决策 1 修改 ddl.sql、migrations.go、db.go,新增两个增量脚本。运行 go test ./internal/storage/ -run 'SchemaVersion|Epoch|Migration|V14' -count=1 预期全绿;再跑 go test ./internal/storage/ -count=1 确认 TestSQLiteFreshSchemaMatchesIncrementalIdentityChain 与升级链测试未被破坏。
步骤 3:心跳自愈与解耦(先红)
internal/server/client_connection_lease_test.go新增TestClientConnectionLeaseHeartbeatSelfHealsEpochMismatch:用一个Renew对真实 epoch 返回sql.ErrNoRows、但接受重登记的 fake repository,断言Heartbeat最终无错,且Register收到的 epoch 等于内存中的真实 epoch。internal/server/client_observability_test.go新增TestClientObservabilityHeartbeatTouchesMetadataWhenLeaseFails:让租约控制器持续失败,断言TouchInstance仍被调用且返回的错误中包含租约侧错误。
预期失败:自愈测试报 sql.ErrNoRows;解耦测试报 TouchInstance 调用次数为 0。
步骤 4:最小实现服务端修复(转绿)
按决策 2、3 修改 client_connection_lease.go 与 client_observability.go。运行 go test ./internal/server/ -run 'ClientConnectionLease|ClientObservability' -count=1 预期全绿。
步骤 5:前端(先红)
新增 web/src/tests/clients-view.spec.ts,参照 web/src/tests/agents-view.spec.ts 的挂载方式:
- 汇总卡片渲染 5 项,且
stale行的数量被正确统计。 - 详情抽屉中
expiresAt在未来的连接标记为活跃、在过去连接标记为已过期。 - 已过期连接不渲染「关闭连接」按钮。
预期失败:卡片数量断言为 4;租约状态列文案不存在。
步骤 6:最小实现前端(转绿)
按决策 4、5 修改 Clients.vue 与两份 i18n 消息,cd web && npm test -- --run 预期全绿。
8. 验证命令
go build ./...
go test ./... -count=1
go test -race ./...
go vet ./...
git diff --check
cd web && npm test -- --run && npm run build && cd ..
rsync -a --delete web/dist/ internal/server/web_dist/
./scripts/verify-web-embed.sh
python3 scripts/gen_doc_index.py
MySQL 侧如具备环境,额外执行:
TUNNELMESH_TEST_MYSQL_DSN='<dsn>' go test ./internal/storage/ -run MySQL -count=1
9. 发布与回滚
- 发布范围:仅 Server 需要升级。Agent 与 Client 二进制无改动,协议帧、能力协商与 API 契约均未变化,不需要重新分发客户端。
- 升级顺序:备份 → 确认
schema_meta.version=14→ 启动新版 Server(storage.auto_init: true)自动执行v0014_to_v0015→ 校验SHOW COLUMNS FROM client_connection_leases LIKE 'connection_epoch'返回bigint。 - 自愈窗口:迁移完成后,已连接的 Client 在下一个心跳周期(默认 30s)内由决策 2 修正被钳制的 epoch,列表与卡片随即恢复真实数值,无需重启客户端。
- 锁表影响:
MODIFY到更宽的整型在 MySQL 8.0 可走ALGORITHM=INPLACE;5.7 及以下会重建表。client_connection_leases行数上界为活跃物理连接数(通常百级),agent_connection_leases同量级,预计锁表时间秒级,可在业务低峰执行。 - 回滚:列宽收窄回
INTEGER会再次触发钳制,属于不可逆风险,因此回滚策略是保留 v15 Schema 并回退应用版本;旧版 Server 读BIGINT列得到 int64,功能正常。确需还原结构时只能通过升级前备份恢复。 - 5 分钟止损:若新版 Server 启动失败,直接回退二进制即可,v15 Schema 对旧版本向后兼容,无需回滚数据库。
10. Reviewer 关注点
MODIFY connection_epoch BIGINT在 MySQL 5.6/5.7/8.0 上的重复执行是否确实幂等(升级中断重试路径)。- 决策 2 的自愈是否可能与并发
Release竞争,把一条正在关闭的连接重新登记成活跃租约。 - 决策 3 用
errors.Join后,ws_client.go:173的_ = clientObservability.Heartbeat(...)仍然丢弃错误,是否需要补日志或指标。 - 详情抽屉的
now基准刷新时机是否足够,长时间停留的抽屉会不会重新产生状态漂移。