16-freshness-and-stale-state.md 11 KB

16: 数据新鲜度与过期状态

What to build: 建立统一新鲜度模型。连续两个配置周期未成功更新时,页面显示数据不可用或已过期,并保留最后一次成功数据但不伪装成实时数据。

Blocked by: 15

Status: resolved Labels: ready-for-agent

  • 显示最近一次成功更新时间。
  • 数据新鲜、过期、不可用和部分失败状态可区分。
  • 连续两个周期失败时显示过期或不可用状态。
  • 过期数据保留最后已知值,并带有过期标识。
  • 缺失指标显示占位符,不显示虚假零值。
  • 有新鲜度计算单测。

进度:100%(已关闭)

2026-09-24 真机 DOM 走查完成(http://127.0.0.1:8001/#/cockpit):A–E 五组逐项通过, 逻辑闸门 43/43、20 个逻辑闸门全量回归绿。走查过程中抓到一个验收名存实亡的缺口并补上 (「部分失败」原本是没人消费的死代码),详见下文。

真机走查结果(从 DOM/服务读,不读内部变量)

  • A 最近成功更新时间:顶栏 data-freshness=current、data-freshness-at=16:59:33、 文案「更新 16:59:33 · 数据新鲜」。
  • B 连续两个失败周期 → 过期:先成功一轮(最后成功 10:00:20),再让两个周期失败 (WS 断线,REST 仍能取数)—— 第 1 个失败周期仍是 current,第 2 个转 stale, retained=true、仍显示 10:00:20(20 秒前),原因是「状态推送未连接」。 「连续两个」是连续不是累计:中间成功一次就清零(逻辑闸门 C4 守住)。
  • C 从未取到 → 不可用:从未成功过 + 两个失败周期 → unavailable、retained=false、 时刻显示占位符 「—」(不编一个 00:00:00 冒充真实时刻)——与「过期」明确分开。
  • D 部分失败:把一个共享分区标成 failed → 顶栏 data-freshness-overall=partial, 界面真的画出「· 部分数据过期或不可用」。这条是跨分区性质:单块面板只会说自己那一份。
  • E 离开即停轮询:离开 /cockpit 前 freshnessPolling()=true、离开后 false 且外壳已卸载 —— 定时器不跟着页面跑下去(票 15 刚在「漏取消定时器」上栽过)。
  • 无页面级报错(pageerror/console.error 全程为空)。

环境备注:本机 @puppeteer/browsers 是空目录,t16-freshness-walkthrough.mjs 起不来 (与 t28 同一环境问题),故上述 A–E 用内建浏览器驱动同一批断言完成;脚本已保留成可复跑形式。

先定的三处口径(本次会话向维护者确认,不猜)

决策地图票 07 的 Q3 把「WebSocket 失败降级轮询与新鲜度语义」判给了本票,但票面没定义 「一个周期」是什么。定下来的三条:

  1. 前端轮询 + WS 断线也算失败周期:新增配置 runtime.refreshSeconds(默认 30 秒), 每个周期做一次快照刷新;取数成功且 WS 可用才算这一周期成功 —— 只取数成功但推送断着, 说明这条链路的"实时性"已经没了,那正是要如实告诉值班人员的事。
  2. 复用分区状态词汇 + 顶栏「最近成功更新」:过期/不可用沿用票 05 的 STALE/UNAVAILABLE 与 sectionView 文案(不造第二套词汇),另在顶栏加一处「更新 HH:mm:ss · 四态」胶囊。
  3. 注入式时钟 + mock 开关:与票 15 的假 socket 同一路子 —— 单测与走查用假时钟推进 "两个周期",真实环境走同一条代码路径;__COCKPIT_FRESHNESS_FAIL__ 演示持续失败。

交付物

  • domain/freshnessModel.js(新):统一新鲜度模型的纯函数层 —— 四态判据 (CURRENT/STALE/EMPTY/UNAVAILABLE)、连续两个失败周期即过期、 时间与年龄文案(缺失一律占位符)、跨分区的 overallFreshness()(partial 是独立状态)、 以及与分区状态/后端 freshness 的显式翻译(各只有一处)。
  • services/freshnessService.js(新):刷新周期与轮询。时钟、定时器全注入; startPolling 启动即跑一轮(否则首屏要空等一个周期);stopPolling 必须清干净; recordExternal 供推送到达等事件驱动路径记账。
  • stores/runtimeStore.js:ADR 0004 明确把「最后成功刷新时间、过期状态」归它 —— 新增 freshnessState/freshness/retained/freshnessSectionState 与 startFreshness/stopFreshness/freshnessPolling/recordFreshness; 推送到达即记一次成功(否则一个安静的推送周期会被误判成过期); WS 可用性取自本 store 的连接状态(方向不反:service 不认识 store)。
  • services/cockpitConfig.js:新增 runtime.refreshSeconds(默认 30,正数校验 —— 0/负数会让"两个周期"永远不成立)与 runtime.polling。
  • CockpitShell.vue:顶栏新鲜度胶囊(data-freshness / data-freshness-at / data-freshness-overall / data-freshness-partial 是走查断言面)+ 启动/停止周期。
  • verify/t16-freshness.mjs(新,43 项逻辑闸门)、verify/t16-freshness-walkthrough.mjs(新,真机走查)。

验收自查(逐条对着验收原句)

  1. 最近一次成功更新时间:A1–A4 —— 顶栏显示 HH:mm:ss + 「多久之前」,两者任一缺失都显示占位符 (不是 00:00:00,那看起来像一个真实时刻)。
  2. 四态可区分:B1–B5 —— 四态各不相同、四句文案互不相同(过期说「保留最后一次成功数据」, 不可用说「尚未取到过数据」);partial(部分失败)是独立状态,一对一全坏才算整体不可用。
  3. 连续两个周期失败:C1–C5 + F3–F6 —— 阈值 2(与票 05 的 EXPIRED_MISSED_CYCLES 一致); 第 1 个周期失败仍是新鲜、第 2 个才过期;中间成功一次即清零(连续不是累计); 从未成功过 → 不可用(没有旧值可留);WS 断线与取数失败两条路都进同一本账。
  4. 过期保留最后已知值 + 过期标识:D1–D4 + 真机 B 组 —— 过期后 lastSuccessAt 不变、 retained=true,界面带 is-stale(琥珀色)与「显示最后一次成功数据」。
  5. 缺失不显示虚假零值:E1–E5 —— 年龄缺失显示「—」、时间缺失显示「—」、 后端真给 0 是有效数据照实显示;从未成功时不编时间戳。
  6. 新鲜度计算单测:t16 逻辑闸门 43/43 + 四个变异测试确认守卫有牙(见下)。

验证证据

  • node .scratch/cockpit-shell-panel-layout/verify/t16-freshness.mjs → 43/43(七段: 最近成功时间 / 四态可区分 / 连续两个周期 / 过期保留旧值 / 不显示虚假零值 / 刷新周期与清理 / 静态守卫)。
  • 变异测试(守卫确实会变红): ① 阈值改成 1 → C1/C2/C4/F4 四红; ② 失败时清掉 lastSuccessAt(丢失旧值)→ D1/D2 红; ③ 缺失年龄回「0 秒前」→ A2/E5 红(这条第一版没红 —— 暴露了一个假的通过,见下); ④ WS 断线不算失败周期 → F4/F9 红。
  • 回归全绿(20 个逻辑闸门):t01、t04、t05(227)、t06、t07、t08、t09、t10、t11、t12、t13、 t14、t15、t16、t17、t18、t21-terrain、t21-view-modes、t29、t30。
  • 按项目规则未跑 yarn build(AGENTS.md)。

真机走查脚本(可复跑)

node .scratch/cockpit-shell-panel-layout/verify/t16-freshness-walkthrough.mjs

A 顶栏画出「更新 HH:mm:ss · 四态」;B 连续两个失败周期 → stale + is-stale; C 从未取到 → unavailable + 时刻「—」;D 坏一块分区 → data-freshness-overall=partial; E 离开 /cockpit 后 freshnessPolling() 变 false。 (本机 @puppeteer/browsers 为空目录时脚本起不来,改用内建浏览器驱动同一批断言。)

对抗式自查抓到的问题

  1. 「部分失败」原本是没人消费的死代码(最值得记的一条):我把 overallFreshness() 写在 domain 里、逻辑闸门也测了它,但它没有任何界面消费 —— 于是验收第 2 条点名的 「部分失败状态可区分」实际上名存实亡(纯函数绿着,值班人员看不到)。 真机走查时才发现:界面上根本找不到"部分失败"这几个字。已修:外壳把各分区状态收上来交给 overallFreshness(),顶栏胶囊在 partial 时补一句「· 部分数据过期或不可用」; 并补 G9/G10 两条守卫(断言它真的被界面消费,而不只是存在于 domain)。 —— 又一次「纯函数绿 ≠ 用户看得见」。
  2. 一个假的通过(变异测试才暴露):E1 只测了 domain/freshness.js 的 dataAgeText, 而 freshnessModel.js 的 sinceSuccessText 走的是另一条路、当时没有断言; 把它的占位符改成「0 秒前」时闸门仍 40/40 全绿。已补 E5 覆盖两个年龄入口, 复测确认变异会红(A2+E5)。

边界与不追认

  • 轮询只刷新地图摘要:tick 当前重取一份快照(mapStore.refresh())。四块面板的 周期性重取留给后续需要时接上同一本账(runtimeStore.startFreshness({ tick }) 就是那个接口)—— 本票重点是统一新鲜度模型与两个周期的判据,不是把每个端点都挂上轮询。
  • 后端逐设备 freshness 与前端统一模型是两件事:后端给了 missedCycles/freshness 时 仍以它为准(票 05 的 isExpired()),本层负责"前端自己观察到的那一份" (WS 断了、轮询失败了,后端可能根本推不过来)。两者用 viewFromBackendFreshness() 桥接。
  • 应用层心跳仍未做(票 15 遗留):连接活性由 open/close/error 表达,ping/pong 未接。

Answer

统一新鲜度模型落地:连续两个配置周期未成功更新即判过期(有旧值)或不可用(从未取到), 过期保留最后已知值且明确标注、绝不伪装成实时;最近成功更新时间可见;缺失值一律占位符。

  • 判据:domain/freshnessModel.js 纯函数 —— 阈值 2(与后端 missedCycles >= 2 同口径)、 连续而非累计(任何一次成功清零)、从未成功过则落「不可用」而不是「过期」 (因为没有旧值可留,这两件事必须分开说)。
  • 周期:services/freshnessService.js 的轮询,runtime.refreshSeconds 给长度(默认 30 秒); 一个周期取数成功且 WS 可用才算成功(维护者口径:WS 断线也算失败周期); 时钟与定时器全注入,所以"两个周期"能同步断言、也能现场演示。
  • 界面:顶栏一枚胶囊说「更新 HH:mm:ss · 四态」,过期时转琥珀并加 is-stale; 跨分区的「部分失败」也在这枚胶囊里补一句(判据来自 domain,界面不数数)。
  • 缺失不造假:时间是 null 就显示「—」,不编 00:00:00;年龄缺失显示「—」, 不回「0 秒前」;后端真给 0 照实显示。

验证:逻辑闸门 43/43 + 四个变异测试有牙 + 真机 A–E 五组逐项通过 + 20 个逻辑闸门全量回归绿。 走查抓出「部分失败」是死代码(验收名存实亡)并补上消费与守卫。