fix(flow): 时间轴两处诚实性缺陷(X1)

① round 号跨重启被复用 ⇒ 时间轴把多个真实轮次并成一块(真机实测 web 的 r:1 挤了 4 路、
   startedAt 跨 13:52→21:05)。修法:发号前从落盘播种(MaxRound,json_extract + json_valid 挡坏行),
   每会话每进程只播一次、失败只 WARN 且不重试、并发首轮共享一次读;接口形状零变化
② truncated 一个字段兼两个意思 ⇒ 一页没拉完时前端会说「更早的轮次已过保留期」(假话)。
   新增 retentionBoundaryReached(= 保留期启用 且 已翻到底 且 现存最早一轮 round>1 ⇒ 确有轮次被清扫),
   truncated 语义一字未改;盲区如实登记(整段被清扫会说少、最早一轮落盘失败可能多说)
3 个变异自检(不落账/floor>0/去掉 hasMore 守卫)
This commit is contained in:
AskaEth committed 2026-10-06 21:16:21 +08:00
1 parent b36798265a
commit 2bf3b536dd
9 files changed
+820 -12

No files matched your search

+51
View File
@@ -20,6 +20,9 @@ package flowgraph
// 3. **查不到就是查不到**:已过保留期的行已被 `gc.go` 清扫、从未落盘的行本就不存在 ——
// 两者都不出现在返回值里,本包**不猜**、绝不用别的轮次顶替。保留期口径由调用方经
// `Retention()` 如实转达给客户端(「更早的轮次已过保留期」这句话不能靠前端编)。
// 4. **清扫事实单独判**(切片 X1 新增,见 `EarlierRoundsSwept`):`Timeline` 的第二个
// 返回值只回答「本次是不是全部」(`hasMore`),它**不**回答「更早的轮次是不是被
// 保留期清扫掉了」—— 那是另一件事、另一个判据。
import (
"context"
@@ -284,6 +287,54 @@ func mergeLane(byKey map[string]*TimelineRound, g *trace.FlowGraph, createdAt st
return nil
}
// EarlierRoundsSwept 报告「该会话**确有更早的轮次已被保留期清扫删掉**」这一事实
// (design/19 §7.1 的清扫信号;切片 X1,2026-10-06)。
//
// **它补的是真机挖出的第二处真缺陷**:此前契约与设计把 `truncated` 读作「更早的轮次
// 已过保留期」,而后端的 `truncated = hasMore || !storeWired` 说的是「本次不是全部」
// —— 一页还没拉完时,面板就会**对着仍然可翻的历史说假话**。两件事必须分开:
// `truncated` 回答「本次是不是全部」,本函数回答「更早的是不是被清扫掉了」。
//
// **判据(一条链,逐环可复核)**:
// 1. 轮次号**自 1 起**:一个会话的第一次发号必然是 1(`loop` 的计数器从 0 起,其轮
// 次号 = 该轮首路的建图序号),且此后**每会话单调**(跨重启也单调 —— 发布器在
// 本进程第一次给该会话发号之前,用 `MaxRound` 把计数器播到已落盘的最大值之上);
// 2. 因此「现存**最早**一轮的轮次号 > 1」只可能由**删除**造成:号更小的那些轮次确实
// 发过号(= 确实建过图、收尾时确实尝试落盘);
// 3. `flow_graphs` 的**唯一**删除者就是保留期清扫(`gc.go`:按 `created_at` 删过期行;
// 读取侧只读、发布器只 upsert)。
// ⇒ 最早一轮的轮次号 > 1 时,「更早的轮次已被保留期清扫」成立。
//
// **调用方必须保证 rounds 是该会话**完整**的轮次列表**(即 `Timeline` 返回 `hasMore`
// 为 false 的那一页,可再并入内存里的在飞路):只看一页就说「已过保留期」,正是本切片
// 要治的那句假话。本函数是**纯判断**,不查库、不猜时间。
//
// **诚实的盲区(不美化)**:① 一个会话若**整段历史都被清扫**(一条可用行都不剩),
// 发号会从 1 重新开始 ⇒ 本函数无法断言,返回 false(**少说**,绝不多说);② 同一会话
// 里有轮次号缺席的老行时同样返回 false(号不可信);③ 唯一可能**错报**的路径是该会话
// 最早的轮次**落盘失败**(`RecordRound` 报错,发布器记 WARN)—— 那一轮从未进库、号却
// 已用掉,本函数会把它算成「已清扫」。概率低、只在库写失败时出现,如实登记在
// design/19 §7 与契约里,不掩盖。
func EarlierRoundsSwept(rounds []TimelineRound) bool {
if len(rounds) == 0 {
return false
}
if rounds[0].Round <= 0 {
return false
}
floor := rounds[0].Round
for i := 1; i < len(rounds); i++ {
if rounds[i].Round <= 0 {
// 有轮次号未知的轮次 ⇒ 「号更小的都删过了」这条推论不成立,保守为否。
return false
}
if rounds[i].Round < floor {
floor = rounds[i].Round
}
}
return floor > 1
}
// roundsInKeyOrder 按 SQL 给出的键次序(最新 ⇒ 最旧)装配轮次并**反转为升序**
// (时间轴从上往下读成时间序)。键没有对应行时跳过 —— 那意味着并发清扫刚删掉了它。
func roundsInKeyOrder(byKey map[string]*TimelineRound, keys []string) []TimelineRound {