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

@@ -0,0 +1,114 @@
package flowgraph
// 本文件钉住**轮次计数器的播种读侧**(design/19 §6.1「`graph.round`:每会话单调」;
// 切片 X1):`MaxRound` 如实交回该会话**已落盘**的最大轮次号,坏行/未知轮次/别的会话
// 的行都不参与取值,读不到时如实报错而不是回 0。
//
// 断言的是**库里读回来的事实**(数值本身),不按日志断言 —— 与 flowgraph_test.go /
// timeline_test.go 同一口径。发布器侧「拿这个值播种之后轮次号跨重启不重用」的判据在
// `internal/runtime/loop/flow_roundseed_test.go`(同一切片的另一半)。
import (
"context"
"errors"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maxRoundOf 取某会话的最大轮次号(失败即 Fatal)。
func maxRoundOf(t *testing.T, st *SQLStore, session string) int {
t.Helper()
n, err := st.MaxRound(context.Background(), session)
if err != nil {
t.Fatalf("MaxRound(%s): %v", session, err)
}
return n
}
// timelineRoundNoKey 造一行**轮次号缺席**的老图(`round` 键根本不写入 —— Round 为 0
// 时 `trace` 的 JSON 编码不带该键,与真机上那批旧行同形)。
func timelineRoundNoKey(id, session string) *trace.FlowGraph {
return &trace.FlowGraph{
ID: id,
SessionID: session,
StartedAt: testNow().Add(-time.Minute),
EndedAt: testNow(),
Nodes: []trace.FlowNode{{ID: "input", Label: "你好", Kind: "input"}},
}
}
// TestMaxRound_PicksLargestPersistedRound_正常 钉住取值本身:多轮取最大,缺失轮次号的
// 老行(Round 0)与**别的会话**的行都不干扰。
func TestMaxRound_PicksLargestPersistedRound_正常(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, nil)
if got := maxRoundOf(t, st, "web"); got != 0 {
t.Fatalf("空会话 MaxRound = %d, want 0(库里没有该会话的行)", got)
}
recordTimelineRound(t, st, timelineGraph("web", 3, 3, "turn-c", testNow()))
recordTimelineRound(t, st, timelineRoundNoKey("web-old", "web"))
recordTimelineRound(t, st, timelineGraph("web", 7, 9, "turn-g", testNow()))
recordTimelineRound(t, st, timelineGraph("other", 42, 42, "turn-x", testNow()))
if got := maxRoundOf(t, st, "web"); got != 7 {
t.Fatalf("MaxRound(web) = %d, want 7(最大值;老行与别的会话都不算)", got)
}
if got := maxRoundOf(t, st, "other"); got != 42 {
t.Fatalf("MaxRound(other) = %d, want 42(按会话隔离,绝不串会话)", got)
}
if got := maxRoundOf(t, st, "sess-im-stranger"); got != 0 {
t.Fatalf("MaxRound(白名单外会话) = %d, want 0(它从不落盘)", got)
}
}
// TestMaxRound_BrokenJSONRowIsIgnored_边界 钉住坏行**不拖垮**播种:`graph_json` 破损
// 的那一行从不参与解析(SQL 侧 `json_valid` 保护),查询照常给出其余行的最大值。
func TestMaxRound_BrokenJSONRowIsIgnored_边界(t *testing.T) {
t.Parallel()
st, conn := newTestStore(t, nil)
recordTimelineRound(t, st, timelineGraph("web", 5, 5, "turn-e", testNow()))
recordTimelineRound(t, st, timelineGraph("web", 6, 6, "turn-f", testNow()))
if _, err := conn.ExecContext(context.Background(),
`UPDATE flow_graphs SET graph_json = '{ oops' WHERE turn_id = ?`, "web-6#turn-f"); err != nil {
t.Fatalf("把一行做坏: %v", err)
}
if got := maxRoundOf(t, st, "web"); got != 5 {
t.Fatalf("MaxRound = %d, want 5(坏行被忽略;它绝不能让整条查询报错)", got)
}
}
// TestMaxRound_NonPositiveRoundIsUnknown_边界 钉住缺席语义:`round` 为 0 / 负数 /
// 非数字(`CAST` 归 0)的行都算「未知轮次」,绝不冒充一个真实轮次号 —— 因此一个只有
// 这类行的会话回 0,而不是回一个假的「第 -5 轮」。
func TestMaxRound_NonPositiveRoundIsUnknown_边界(t *testing.T) {
t.Parallel()
st, conn := newTestStore(t, nil)
recordTimelineRound(t, st, timelineGraph("web", 4, 4, "turn-d", testNow()))
recordTimelineRound(t, st, timelineGraph("neg", 9, 9, "turn-n", testNow()))
if _, err := conn.ExecContext(context.Background(),
`UPDATE flow_graphs SET graph_json = json_set(graph_json, '$.round', -5) WHERE turn_id = ?`,
"neg-9#turn-n"); err != nil {
t.Fatalf("把 round 改成负数: %v", err)
}
if got := maxRoundOf(t, st, "neg"); got != 0 {
t.Fatalf("MaxRound(只有负轮次号) = %d, want 0(未知不冒充真实轮次)", got)
}
if got := maxRoundOf(t, st, "web"); got != 4 {
t.Fatalf("MaxRound(web) = %d, want 4(别的会话的负值不该影响它)", got)
}
}
// TestMaxRound_NilDBIsHonest_异常 钉住未接数据库时如实报 `ErrNilDB`:绝不静默回 0
// —— 那会让发布器把「读不到」当成「库里没有轮次」而发一个会撞旧行的号。
func TestMaxRound_NilDBIsHonest_异常(t *testing.T) {
t.Parallel()
var nilStore *SQLStore
if _, err := nilStore.MaxRound(context.Background(), "web"); !errors.Is(err, ErrNilDB) {
t.Fatalf("nil store 的 err = %v, want ErrNilDB", err)
}
}