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:
1 parent
b36798265a
commit
2bf3b536dd
9 files changed
+820
-12
No files matched your search
@@ -19,8 +19,13 @@ package gateway
|
||||
// 严格早于它的轮次」,响应带 `nextBefore` 供客户端原样回带(分页不重不漏的口径由
|
||||
// `flowgraph.SQLStore.Timeline` 保证并测试)。
|
||||
// 4. **保留期如实**:过期轮次已被清扫,不在返回值里;响应给 `retentionHours` /
|
||||
// `retentionEnabled` / `truncated`,由前端据此标注「更早的轮次已过保留期」——
|
||||
// 绝不静默少一块(design/19 §7.1 的原话)。
|
||||
// `retentionEnabled` / `truncated` / `retentionBoundaryReached`,由前端据此标注
|
||||
// 「更早的轮次已过保留期」——绝不静默少一块(design/19 §7.1 的原话)。
|
||||
// 🔴 **两个字段各说一件事**(切片 X1 修的一处真缺陷):`truncated` 只回答「本次是不是
|
||||
// 全部」(`hasMore` 或落盘源未接线),`retentionBoundaryReached` 才回答「更早的轮次
|
||||
// 是不是**确实被保留期清扫掉了**」——后者由 `flowgraph.EarlierRoundsSwept` 给出判据
|
||||
// (该会话现存最早一轮的轮次号 > 1 ⇒ 号更小的轮次曾落盘、且已被清扫)。混用会让面板
|
||||
// 对着**仍然可翻**的历史说「已过保留期」,那是假话。
|
||||
//
|
||||
// 未接线(内存与落盘**都没接**)时回 `500 flow_unavailable`,与老出口同一码、同一口径:
|
||||
// 绝不回一份空的 `rounds` 把「图源没接线」伪装成「这台会话没有轮次」。
|
||||
@@ -103,8 +108,25 @@ type adminFlowTimelineResponse struct {
|
||||
// HasMore 报告库里**还有更早的轮次**没被本次返回(用 NextBefore 继续取)。
|
||||
HasMore bool `json:"hasMore"`
|
||||
// Truncated 报告本次**不是全部**:`hasMore`(分页未到底)或落盘源未接线(只剩内存
|
||||
// 最近的这一轮)。前端据此如实标注「更早的轮次未显示」,**绝不静默少一块**。
|
||||
// 最近的这一轮)。它是**分页口径**,不是保留期口径 —— 面板据此标注「更早的轮次未
|
||||
// 显示」,**绝不静默少一块**。要问「更早的是不是被清扫掉了」请看
|
||||
// `retentionBoundaryReached`(两个字段各说一件事,切片 X1 修的正是这处混用)。
|
||||
Truncated bool `json:"truncated"`
|
||||
// RetentionBoundaryReached 报告**保留期边界已到达**:该会话**确有更早的轮次已被
|
||||
// 保留期清扫删掉**,且本次已取到它现存最早的一端(切片 X1 新增;契约版本待主会话
|
||||
// 登记 —— 本字段需一次版本号,建议 v0.81)。
|
||||
//
|
||||
// 判据(三条一起成立,逐条可复核):① 保留期清扫已启用(`retentionEnabled`);
|
||||
// ② `hasMore:false`(本次不是半页 —— 只看半页就说「已过保留期」正是修掉的那句假话);
|
||||
// ③ `flowgraph.EarlierRoundsSwept`:该会话现存最早一轮的轮次号 > 1(轮次号自 1 起、
|
||||
// 每会话跨重启单调 ⇒ 号更小的轮次曾落盘,而 `flow_graphs` 的唯一删除者就是保留期
|
||||
// 清扫)。⇒ **客户端只有看到 `true` 才可以说「更早的轮次已过保留期」**,其余情形一律
|
||||
// 说「更早的轮次未显示」(`truncated`)或「只拿到最近一轮」(`source:"live"`)。
|
||||
//
|
||||
// 诚实的盲区(写在契约里,不掩盖):一个会话若**整段历史都被清扫**(一条可用行都不
|
||||
// 剩),发号从 1 重新开始 ⇒ 本字段为 false(**少说**,绝不多说);有轮次号未知的老行
|
||||
// 在场时同样为 false。
|
||||
RetentionBoundaryReached bool `json:"retentionBoundaryReached"`
|
||||
// RetentionHours 是落盘保留期(小时,下取整);`RetentionEnabled` 为 false 时保留期
|
||||
// 清扫**未启用** ⇒ "更早的轮次" 不是被清扫掉的,别把它说成过期。
|
||||
// `FLOW_GRAPH_RETENTION` 是既有旋钮(默认 168h),本片**不新增任何配置键**。
|
||||
@@ -169,17 +191,34 @@ func (s *Server) adminFlowTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
hasMore = true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, adminFlowTimelineResponse{
|
||||
SessionID: sessionID,
|
||||
Source: timelineSource(persisted, len(merged) > 0),
|
||||
Rounds: flowTimelineRoundsOf(merged),
|
||||
NextBefore: timelineNextBefore(merged),
|
||||
HasMore: hasMore,
|
||||
Truncated: hasMore || !storeWired,
|
||||
RetentionHours: int(retention / time.Hour),
|
||||
RetentionEnabled: retention > 0,
|
||||
SessionID: sessionID,
|
||||
Source: timelineSource(persisted, len(merged) > 0),
|
||||
Rounds: flowTimelineRoundsOf(merged),
|
||||
NextBefore: timelineNextBefore(merged),
|
||||
HasMore: hasMore,
|
||||
Truncated: hasMore || !storeWired,
|
||||
// 保留期边界:只有「清扫已启用 + 已到最早一端 + 确有更早的轮次被清扫删掉」三条
|
||||
// 同时成立才为 true(判据见 DTO 注释与 design/19 §7.1)。
|
||||
RetentionBoundaryReached: retainedEarlierRoundsSwept(merged, hasMore, retention),
|
||||
RetentionHours: int(retention / time.Hour),
|
||||
RetentionEnabled: retention > 0,
|
||||
})
|
||||
}
|
||||
|
||||
// retainedEarlierRoundsSwept 是 `retentionBoundaryReached` 的判据本体:清扫已启用
|
||||
// (retention > 0)、本次已到该会话现存最早的一端(!hasMore)、且该会话**确有**更早的
|
||||
// 轮次已被清扫删掉(`flowgraph.EarlierRoundsSwept` 的轮次号下界判据)。
|
||||
//
|
||||
// 三条缺一不可:少了第一条会说「保留期」却根本没开清扫;少了第二条会对着**仍然可翻**的
|
||||
// 历史说「已过保留期」(切片 X1 修掉的那句假话);少了第三条会把一个刚开张、什么都没被
|
||||
// 清掉的会话说成「更早的轮次已过保留期」。
|
||||
func retainedEarlierRoundsSwept(merged []flowgraph.TimelineRound, hasMore bool, retention time.Duration) bool {
|
||||
if retention <= 0 || hasMore {
|
||||
return false
|
||||
}
|
||||
return flowgraph.EarlierRoundsSwept(merged)
|
||||
}
|
||||
|
||||
// timelineLimitParam 解析 `?limit=`:空串 = 未给(0 ⇒ 存储层用缺省 `DefaultTurnsLimit`);
|
||||
// 非法整数报错(400,绝不静默当缺省);负值归一成 0;超过硬上限 `MaxTurnsLimit` 即钳住。
|
||||
func timelineLimitParam(raw string) (int, error) {
|
||||
|
||||
@@ -52,6 +52,7 @@ type flowTimelineResponseDTO struct {
|
||||
NextBefore string `json:"nextBefore"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
Truncated bool `json:"truncated"`
|
||||
BoundaryReached bool `json:"retentionBoundaryReached"`
|
||||
RetentionHours int `json:"retentionHours"`
|
||||
RetentionEnabled bool `json:"retentionEnabled"`
|
||||
}
|
||||
@@ -386,6 +387,150 @@ func TestAdminFlowTimeline_ExpiredRowsAbsentRetentionReported_正常(t *testing.
|
||||
if rounds := fmt.Sprint(roundNos(t, got.Rounds)); rounds != "[2]" {
|
||||
t.Fatalf("轮次 = %s, want [2](过期那轮已被清扫,绝不出现在时间轴里)", rounds)
|
||||
}
|
||||
// 🔴 清扫信号(切片 X1):一轮**真被清扫删掉**(上面的 Sweep)⇒ 现存最早一轮是第 2 轮
|
||||
// ⇒ 保留期边界已到达 ⇒ 面板**可以**说「更早的轮次已过保留期」。这是本字段唯一正当的
|
||||
// 出处:不是「返回的轮次比想象中少」,而是「确有轮次被清扫删掉」。
|
||||
if !got.BoundaryReached {
|
||||
t.Fatal("retentionBoundaryReached = false,但第 1 轮确实被 Sweep 删掉、且本次已翻到最早一端")
|
||||
}
|
||||
if got.Truncated {
|
||||
t.Fatal("truncated = true,但本次已经是全部(已翻到最早一端、落盘源已接线)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminFlowTimeline_RoundNumbersSurviveRestart_正常 是本切片缺陷①的**真判据**
|
||||
// (主人给的原话):「重启前后各跑一轮 ⇒ 时间轴里是两块、标题轮次不重复」。
|
||||
//
|
||||
// 走**生产形状**:真 SQLite 文件库 + 真 store 作落盘出口(发布器的播种能力正是从这个
|
||||
// 出口上探到的 ⇒ 这条用例同时钉住「接线零改动,但能力真接上了」)+ 真 HTTP 出口。
|
||||
// 「重启」用换发布器(空内存)+ 同一个文件库的新连接/新 store 模拟(与
|
||||
// `TestAdminFlowGraph_SurvivesRestart_正常` 同一手法)。
|
||||
func TestAdminFlowTimeline_RoundNumbersSurviveRestart_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "cyrene.db")
|
||||
|
||||
// 「重启前」那个进程:跑一轮(轮次号从 1 起,与修复前一致)。
|
||||
conn1, store1 := openFlowGraphStore(t, path)
|
||||
pub1 := flowPublisherWithStore(store1, func(string) bool { return true })
|
||||
_, ts1, _ := buildEnv(t, cfgDefault(), Deps{Flow: pub1, FlowGraphs: store1})
|
||||
runFlowRound(pub1, "web", "重启前那一轮", "答一")
|
||||
ts1.Close()
|
||||
_ = conn1.Close() // 模拟进程退出
|
||||
|
||||
// 「重启后」那个进程:空内存的新发布器 + 同一个文件库的新连接 / 新 store。
|
||||
_, store2 := openFlowGraphStore(t, path)
|
||||
pub2 := flowPublisherWithStore(store2, func(string) bool { return true })
|
||||
_, ts2, jwt2 := buildEnv(t, cfgDefault(), Deps{Flow: pub2, FlowGraphs: store2})
|
||||
tok := signAny(t, jwt2, "acct", auth.RoleOwner)
|
||||
runFlowRound(pub2, "web", "重启后那一轮", "答二")
|
||||
|
||||
got := getTimeline(t, ts2, tok, "web", "", "")
|
||||
if len(got.Rounds) != 2 {
|
||||
t.Fatalf("时间轴轮数 = %d, want 2(重启前后各一轮是**两块**,绝不并成一块): %+v",
|
||||
len(got.Rounds), got.Rounds)
|
||||
}
|
||||
rounds := roundNos(t, got.Rounds)
|
||||
if rounds[0] <= 0 || rounds[1] <= 0 {
|
||||
t.Fatalf("轮次号出现 null/0,但两轮都有轮次号: %v", rounds)
|
||||
}
|
||||
if rounds[0] == rounds[1] {
|
||||
t.Fatalf("两轮轮次号相同(%v)⇒ 时间轴会把它们并成一块、标题轮次重复(本切片要治的缺陷)", rounds)
|
||||
}
|
||||
if rounds[0] >= rounds[1] {
|
||||
t.Fatalf("轮次号 %v 不是递增(时间正序)", rounds)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminFlowTimeline_TruncatedIsNotRetention_正常 钉住**两个字段各说一件事**
|
||||
// (切片 X1 修的那处真缺陷):`truncated` 只说「本次不是全部」,`retentionBoundaryReached`
|
||||
// 才说「更早的轮次确实被保留期清扫掉了」。三步都在同一个会话上走:
|
||||
//
|
||||
// ① 只拉一页(还有更早的)⇒ truncated=true 而 boundaryReached=**false**(历史仍可翻,
|
||||
// 「已过保留期」是假话);
|
||||
// ② 翻到底但最早一轮就是第 1 轮 ⇒ 两个都是 false(没有更早的轮次被删,别栽赃给保留期);
|
||||
// ③ 同上但落盘源未接线 ⇒ truncated=true(分页口径)而 boundaryReached=false。
|
||||
func TestAdminFlowTimeline_TruncatedIsNotRetention_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "cyrene.db")
|
||||
conn, _ := openFlowGraphStore(t, path)
|
||||
store, err := flowgraph.NewStore(flowgraph.Config{
|
||||
DB: conn, Log: gwTestLogger(), GCInterval: time.Hour, Retention: 168 * time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new store with retention: %v", err)
|
||||
}
|
||||
base := time.Now().UTC().Add(-3 * time.Hour)
|
||||
for round := 1; round <= 3; round++ {
|
||||
recordTimelineGraph(t, store, "web", round, fmt.Sprintf("turn-%d", round),
|
||||
base.Add(time.Duration(round)*time.Hour))
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{FlowGraphs: store})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// ① 最新那一页(limit=1):还有更早的可翻。
|
||||
page1 := getTimeline(t, ts, tok, "web", "1", "")
|
||||
if got := fmt.Sprint(roundNos(t, page1.Rounds)); got != "[3]" {
|
||||
t.Fatalf("第 1 页轮次 = %s, want [3]", got)
|
||||
}
|
||||
if !page1.HasMore || !page1.Truncated {
|
||||
t.Fatalf("第 1 页 hasMore/truncated = %v/%v, want true/true(本次不是全部)",
|
||||
page1.HasMore, page1.Truncated)
|
||||
}
|
||||
if page1.BoundaryReached {
|
||||
t.Fatal("还有更早的可翻 ⇒ **绝不**能说「更早的轮次已过保留期」(那是对着能翻的历史说假话)")
|
||||
}
|
||||
|
||||
// ② 翻到最早那一页:本次是全部,但现存最早一轮就是第 1 轮 ⇒ 没有被清扫的轮次。
|
||||
page2 := getTimeline(t, ts, tok, "web", "1", page1.NextBefore)
|
||||
if !page2.HasMore {
|
||||
t.Fatal("第 2 页 hasMore = false,但后面还有第 1 轮")
|
||||
}
|
||||
last := getTimeline(t, ts, tok, "web", "1", page2.NextBefore)
|
||||
if got := fmt.Sprint(roundNos(t, last.Rounds)); got != "[1]" {
|
||||
t.Fatalf("最后一页轮次 = %s, want [1]", got)
|
||||
}
|
||||
if last.HasMore || last.Truncated {
|
||||
t.Fatalf("最后一页 hasMore/truncated = %v/%v, want false/false(已翻到底)",
|
||||
last.HasMore, last.Truncated)
|
||||
}
|
||||
if last.BoundaryReached {
|
||||
t.Fatal("现存最早一轮是第 1 轮 ⇒ 没有更早的轮次被清扫 ⇒ 不得说「已过保留期」")
|
||||
}
|
||||
|
||||
// ③ 落盘源未接线:truncated 仍如实为 true(分页口径),保留期信号不越权。
|
||||
pubOnly := loop.NewFlowPublisher(func(string, any) {}, func(string) bool { return true })
|
||||
_, ts2, jwt2 := buildEnv(t, cfgDefault(), Deps{Flow: pubOnly})
|
||||
runFlowRound(pubOnly, "web", "问题", "回答")
|
||||
live := getTimeline(t, ts2, signAny(t, jwt2, "acct", auth.RoleOwner), "web", "", "")
|
||||
if !live.Truncated {
|
||||
t.Fatal("落盘源未接线 ⇒ truncated = true(本次不是全部),这条口径一字未改")
|
||||
}
|
||||
if live.BoundaryReached {
|
||||
t.Fatal("落盘源未接线 ⇒ 保留期边界信号必须为 false(连库都没接,无从谈清扫)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminFlowTimeline_RetentionDisabledNeverClaimsExpiry_边界 钉住第一条合成判据:
|
||||
// **清扫没启用就不存在「保留期」这回事**。库里现存最早的是第 5 轮(本该能推出「更早的
|
||||
// 被删过」),但保留期关闭 ⇒ `retentionBoundaryReached` 必须为 false,且响应如实标注
|
||||
// `retentionEnabled:false` —— 那种情况下的「更早的轮次」不是被清扫掉的,不能说成过期。
|
||||
func TestAdminFlowTimeline_RetentionDisabledNeverClaimsExpiry_边界(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "cyrene.db")
|
||||
_, store := openFlowGraphStore(t, path) // GCInterval/Retention 均为 0 ⇒ 清扫未启用
|
||||
base := time.Now().UTC().Add(-time.Hour)
|
||||
recordTimelineGraph(t, store, "web", 5, "turn-5", base)
|
||||
recordTimelineGraph(t, store, "web", 6, "turn-6", base.Add(time.Minute))
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{FlowGraphs: store})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
got := getTimeline(t, ts, tok, "web", "", "")
|
||||
if got.RetentionEnabled || got.RetentionHours != 0 {
|
||||
t.Fatalf("保留期 = %d h / enabled=%v, want 0 / false", got.RetentionHours, got.RetentionEnabled)
|
||||
}
|
||||
if got.BoundaryReached {
|
||||
t.Fatal("保留期清扫未启用 ⇒ 不得断言「更早的轮次已过保留期」(它们不是被清扫掉的)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminFlowTimeline_UnknownSessionIsHonestEmpty_边界 钉住空态:发布会话范围外的会话
|
||||
|
||||
Reference in new issue
Block a user