Files
AskaEth 2bf3b536dd 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 守卫)
2026-10-06 21:16:21 +08:00

115 lines
4.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}