① 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 守卫)
115 lines
4.9 KiB
Go
115 lines
4.9 KiB
Go
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)
|
||
}
|
||
}
|